mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:32:37 +10:00
Compare commits
4 Commits
feat/resol
...
upgrade/ex
| Author | SHA1 | Date | |
|---|---|---|---|
| 527a6a650f | |||
| ef261565aa | |||
| 698cef55ea | |||
| 30dabd9fc1 |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.0",
|
"version": "0.20.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -16,33 +16,30 @@
|
|||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^8.1.3",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^8.1.3",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"@tiptap/extension-character-count": "^2.10.3",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
"alfaaz": "^1.1.0",
|
"axios": "^1.8.4",
|
||||||
"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",
|
"highlightjs-sap-abap": "^0.3.0",
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "0.16.22",
|
"katex": "0.16.21",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.2.0",
|
||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mermaid": "^11.4.1",
|
||||||
"mermaid": "^11.6.0",
|
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.15",
|
"react-clear-modal": "^2.0.15",
|
||||||
@ -52,11 +49,11 @@
|
|||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.56"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Comment deleted successfully",
|
"Comment deleted successfully": "Comment deleted successfully",
|
||||||
"Failed to delete comment": "Failed to delete comment",
|
"Failed to delete comment": "Failed to delete comment",
|
||||||
"Comment resolved successfully": "Comment resolved successfully",
|
"Comment resolved successfully": "Comment resolved successfully",
|
||||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
|
||||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
"Resolve comment": "Resolve comment",
|
|
||||||
"Unresolve comment": "Unresolve comment",
|
|
||||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
|
||||||
"Unresolve Comment Thread": "Unresolve Comment Thread",
|
|
||||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
|
|
||||||
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
|
||||||
"Resolved": "Resolved",
|
|
||||||
"No active comments.": "No active comments.",
|
|
||||||
"No resolved comments.": "No resolved comments.",
|
|
||||||
"Revoke invitation": "Revoke invitation",
|
"Revoke invitation": "Revoke invitation",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revoke",
|
||||||
"Don't": "Don't",
|
"Don't": "Don't",
|
||||||
@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Copy to space": "Copy to space",
|
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
"Duplicate": "Duplicate",
|
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
|
||||||
"Reading": "Reading",
|
|
||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"Member deleted successfully": "Member deleted successfully",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||||
@ -400,100 +384,7 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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,8 +383,5 @@
|
|||||||
"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": "Страница успешно скопирована"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,390 +0,0 @@
|
|||||||
{
|
|
||||||
"Account": "Обліковий запис",
|
|
||||||
"Active": "Активний",
|
|
||||||
"Add": "Додати",
|
|
||||||
"Add group members": "Додати учасників групи",
|
|
||||||
"Add groups": "Додати групи",
|
|
||||||
"Add members": "Додати учасників",
|
|
||||||
"Add to groups": "Додати до груп",
|
|
||||||
"Add space members": "Додати учасників простору",
|
|
||||||
"Admin": "Адміністратор",
|
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
|
||||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
|
||||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
|
||||||
"Can edit": "Може редагувати",
|
|
||||||
"Can manage workspace": "Може керувати робочою областю",
|
|
||||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
|
||||||
"Can view": "Може переглядати",
|
|
||||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
|
||||||
"Cancel": "Скасувати",
|
|
||||||
"Change email": "Змінити електронну пошту",
|
|
||||||
"Change password": "Змінити пароль",
|
|
||||||
"Change photo": "Змінити фото",
|
|
||||||
"Choose a role": "Оберіть роль",
|
|
||||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
|
||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
|
||||||
"Confirm": "Підтвердити",
|
|
||||||
"Copy link": "Копіювати посилання",
|
|
||||||
"Create": "Створити",
|
|
||||||
"Create group": "Створити групу",
|
|
||||||
"Create page": "Створити сторінку",
|
|
||||||
"Create space": "Створити простір",
|
|
||||||
"Create workspace": "Створити робочу область",
|
|
||||||
"Current password": "Поточний пароль",
|
|
||||||
"Dark": "Темна",
|
|
||||||
"Date": "Дата",
|
|
||||||
"Delete": "Видалити",
|
|
||||||
"Delete group": "Видалити групу",
|
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
|
||||||
"Description": "Опис",
|
|
||||||
"Details": "Деталі",
|
|
||||||
"e.g ACME": "наприклад, ACME",
|
|
||||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
|
||||||
"e.g Developers": "наприклад, Розробники",
|
|
||||||
"e.g Group for developers": "наприклад, Група для розробників",
|
|
||||||
"e.g product": "наприклад, продукт",
|
|
||||||
"e.g Product Team": "наприклад, Продуктова команда",
|
|
||||||
"e.g Sales": "наприклад, Продажі",
|
|
||||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
|
||||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
|
||||||
"Edit": "Редагувати",
|
|
||||||
"Edit group": "Редагувати групу",
|
|
||||||
"Email": "Електронна пошта",
|
|
||||||
"Enter a strong password": "Введіть надійний пароль",
|
|
||||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
|
||||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
|
||||||
"Enter your current password": "Введіть ваш поточний пароль",
|
|
||||||
"enter your full name": "введіть ваше повне ім'я",
|
|
||||||
"Enter your new password": "Введіть ваш новий пароль",
|
|
||||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
|
||||||
"Enter your password": "Введіть ваш пароль",
|
|
||||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
|
||||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
|
||||||
"Export": "Експорт",
|
|
||||||
"Failed to create page": "Не вдалося створити сторінку",
|
|
||||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
|
||||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
|
||||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
|
||||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
|
||||||
"Failed to update data": "Не вдалося оновити дані",
|
|
||||||
"Full access": "Повний доступ",
|
|
||||||
"Full page width": "Ширина на всю сторінку",
|
|
||||||
"Full width": "На всю ширину",
|
|
||||||
"General": "Загальні",
|
|
||||||
"Group": "Група",
|
|
||||||
"Group description": "Опис групи",
|
|
||||||
"Group name": "Назва групи",
|
|
||||||
"Groups": "Групи",
|
|
||||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
|
||||||
"Home": "Головна",
|
|
||||||
"Import pages": "Імпорт сторінок",
|
|
||||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
|
||||||
"Importing pages": "Імпортування сторінок",
|
|
||||||
"invalid invitation link": "посилання на запрошення недійсне",
|
|
||||||
"Invitation signup": "Реєстрація за запрошенням",
|
|
||||||
"Invite by email": "Запросити електронною поштою",
|
|
||||||
"Invite members": "Запросити учасників",
|
|
||||||
"Invite new members": "Запросити нових учасників",
|
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
|
||||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
|
||||||
"Join the workspace": "Приєднатися до робочої області",
|
|
||||||
"Language": "Мова",
|
|
||||||
"Light": "Світла",
|
|
||||||
"Link copied": "Посилання скопійовано",
|
|
||||||
"Login": "Увійти",
|
|
||||||
"Logout": "Вийти",
|
|
||||||
"Manage Group": "Керування групою",
|
|
||||||
"Manage members": "Керування учасниками",
|
|
||||||
"member": "учасник",
|
|
||||||
"Member": "Учасник",
|
|
||||||
"members": "учасники",
|
|
||||||
"Members": "Учасники",
|
|
||||||
"My preferences": "Мої налаштування",
|
|
||||||
"My Profile": "Мій профіль",
|
|
||||||
"My profile": "Мій профіль",
|
|
||||||
"Name": "Ім'я",
|
|
||||||
"New email": "Нова електронна адреса",
|
|
||||||
"New page": "Нова сторінка",
|
|
||||||
"New password": "Новий пароль",
|
|
||||||
"No group found": "Групу не знайдено",
|
|
||||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
|
||||||
"No pages yet": "Сторінок поки немає",
|
|
||||||
"No results found...": "Результати не знайдено...",
|
|
||||||
"No user found": "Користувача не знайдено",
|
|
||||||
"Overview": "Огляд",
|
|
||||||
"Owner": "Власник",
|
|
||||||
"page": "сторінка",
|
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
|
||||||
"Page history": "Історія сторінки",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
|
||||||
"Pages": "Сторінки",
|
|
||||||
"pages": "сторінки",
|
|
||||||
"Password": "Пароль",
|
|
||||||
"Password changed successfully": "Пароль успішно змінено",
|
|
||||||
"Pending": "В очікуванні",
|
|
||||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
|
||||||
"Preferences": "Налаштування",
|
|
||||||
"Print PDF": "Друк PDF",
|
|
||||||
"Profile": "Профіль",
|
|
||||||
"Recently updated": "Нещодавно оновлено",
|
|
||||||
"Remove": "Видалити",
|
|
||||||
"Remove group member": "Видалити учасника групи",
|
|
||||||
"Remove space member": "Видалити учасника простору",
|
|
||||||
"Restore": "Відновити",
|
|
||||||
"Role": "Роль",
|
|
||||||
"Save": "Зберегти",
|
|
||||||
"Search": "Пошук",
|
|
||||||
"Search for groups": "Пошук груп",
|
|
||||||
"Search for users": "Пошук користувачів",
|
|
||||||
"Search for users and groups": "Пошук користувачів та груп",
|
|
||||||
"Search...": "Пошук...",
|
|
||||||
"Select language": "Оберіть мову",
|
|
||||||
"Select role": "Оберіть роль",
|
|
||||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
|
||||||
"Select theme": "Оберіть тему",
|
|
||||||
"Send invitation": "Надіслати запрошення",
|
|
||||||
"Invitation sent": "Запрошення надіслано",
|
|
||||||
"Settings": "Налаштування",
|
|
||||||
"Setup workspace": "Налаштувати робочу область",
|
|
||||||
"Sign In": "Вхід",
|
|
||||||
"Sign Up": "Реєстрація",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"Space": "Простір",
|
|
||||||
"Space description": "Опис простору",
|
|
||||||
"Space menu": "Меню простору",
|
|
||||||
"Space name": "Назва простору",
|
|
||||||
"Space settings": "Налаштування простору",
|
|
||||||
"Space slug": "Slug простору",
|
|
||||||
"Spaces": "Простори",
|
|
||||||
"Spaces you belong to": "Простори, до яких ви належите",
|
|
||||||
"No space found": "Простори не знайдено",
|
|
||||||
"Search for spaces": "Пошук просторів",
|
|
||||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
|
||||||
"Status": "Статус",
|
|
||||||
"Successfully imported": "Успішно імпортовано",
|
|
||||||
"Successfully restored": "Успішно відновлено",
|
|
||||||
"System settings": "Системні налаштування",
|
|
||||||
"Theme": "Тема",
|
|
||||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
|
||||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
|
||||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
|
||||||
"untitled": "без назви",
|
|
||||||
"Untitled": "Без назви",
|
|
||||||
"Updated successfully": "Оновлено успішно",
|
|
||||||
"User": "Користувач",
|
|
||||||
"Workspace": "Робоча область",
|
|
||||||
"Workspace Name": "Ім'я робочої області",
|
|
||||||
"Workspace settings": "Налаштування робочої області",
|
|
||||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
|
||||||
"Your Email": "Ваша електронна пошта",
|
|
||||||
"Your import is complete.": "Ваш імпорт завершено.",
|
|
||||||
"Your name": "Ваше ім'я",
|
|
||||||
"Your Name": "Ваше ім'я",
|
|
||||||
"Your password": "Ваш пароль",
|
|
||||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
|
||||||
"Sidebar toggle": "Перемкнути бічну панель",
|
|
||||||
"Comments": "Коментарі",
|
|
||||||
"404 page not found": "404 сторінку не знайдено",
|
|
||||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
|
||||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
|
||||||
"Forgot password": "Забули пароль",
|
|
||||||
"Forgot your password?": "Забули пароль?",
|
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
|
||||||
"Send reset link": "Надіслати посилання для скидання",
|
|
||||||
"Password reset": "Скидання пароля",
|
|
||||||
"Your new password": "Ваш новий пароль",
|
|
||||||
"Set password": "Встановити пароль",
|
|
||||||
"Write a comment": "Написати коментар",
|
|
||||||
"Reply...": "Відповісти...",
|
|
||||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
|
||||||
"No comments yet.": "Коментарів поки немає.",
|
|
||||||
"Edit comment": "Редагувати коментар",
|
|
||||||
"Delete comment": "Видалити коментар",
|
|
||||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
|
||||||
"Comment created successfully": "Коментар успішно створено",
|
|
||||||
"Error creating comment": "Помилка при створенні коментаря",
|
|
||||||
"Comment updated successfully": "Коментар успішно оновлено",
|
|
||||||
"Failed to update comment": "Не вдалося оновити коментар",
|
|
||||||
"Comment deleted successfully": "Коментар успішно видалено",
|
|
||||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
|
||||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
|
||||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
|
||||||
"Revoke invitation": "Відкликати запрошення",
|
|
||||||
"Revoke": "Відкликати",
|
|
||||||
"Don't": "Ні",
|
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
|
||||||
"Resend invitation": "Надіслати запрошення повторно",
|
|
||||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
|
||||||
"Invite link": "Посилання для запрошення",
|
|
||||||
"Copy": "Копіювати",
|
|
||||||
"Copied": "Скопійовано",
|
|
||||||
"Select a user": "Оберіть користувача",
|
|
||||||
"Select a group": "Оберіть групу",
|
|
||||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
|
||||||
"Delete space": "Видалити простір",
|
|
||||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
|
||||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
|
||||||
"Confirm space name": "Підтвердіть назву простору",
|
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
|
||||||
"Format": "Формат",
|
|
||||||
"Include subpages": "Включити вкладені сторінки",
|
|
||||||
"Include attachments": "Включити вкладення",
|
|
||||||
"Select export format": "Виберіть формат експорту",
|
|
||||||
"Export failed:": "Експортування не вдалося:",
|
|
||||||
"export error": "помилка експорту",
|
|
||||||
"Export page": "Експорт сторінки",
|
|
||||||
"Export space": "Експорт простору",
|
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
|
||||||
"Align left": "По лівому краю",
|
|
||||||
"Align right": "По правому краю",
|
|
||||||
"Align center": "По центру",
|
|
||||||
"Justify": "По ширині",
|
|
||||||
"Merge cells": "Об'єднати комірки",
|
|
||||||
"Split cell": "Розділити комірку",
|
|
||||||
"Delete column": "Видалити стовпець",
|
|
||||||
"Delete row": "Видалити рядок",
|
|
||||||
"Add left column": "Додати стовпець ліворуч",
|
|
||||||
"Add right column": "Додати стовпець праворуч",
|
|
||||||
"Add row above": "Додати рядок вище",
|
|
||||||
"Add row below": "Додати рядок нижче",
|
|
||||||
"Delete table": "Видалити таблицю",
|
|
||||||
"Info": "Інформація",
|
|
||||||
"Success": "Успішно",
|
|
||||||
"Warning": "Попередження",
|
|
||||||
"Danger": "Важливо",
|
|
||||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
|
||||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
|
||||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
|
||||||
"Exit": "Вийти",
|
|
||||||
"Save & Exit": "Зберегти та вийти",
|
|
||||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
|
||||||
"Paste link": "Вставити посилання",
|
|
||||||
"Edit link": "Редагувати посилання",
|
|
||||||
"Remove link": "Видалити посилання",
|
|
||||||
"Add link": "Додати посилання",
|
|
||||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
|
||||||
"Empty equation": "Порожнє рівняння",
|
|
||||||
"Invalid equation": "Неприпустиме рівняння",
|
|
||||||
"Color": "Колір",
|
|
||||||
"Text color": "Колір тексту",
|
|
||||||
"Default": "За замовчуванням",
|
|
||||||
"Blue": "Синій",
|
|
||||||
"Green": "Зелений",
|
|
||||||
"Purple": "Фіолетовий",
|
|
||||||
"Red": "Червоний",
|
|
||||||
"Yellow": "Жовтий",
|
|
||||||
"Orange": "Помаранчевий",
|
|
||||||
"Pink": "Рожевий",
|
|
||||||
"Gray": "Сірий",
|
|
||||||
"Embed link": "Вбудоване посилання",
|
|
||||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
|
||||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
|
||||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
|
||||||
"Bold": "Жирний",
|
|
||||||
"Italic": "Курсив",
|
|
||||||
"Underline": "Підкреслений",
|
|
||||||
"Strike": "Закреслений",
|
|
||||||
"Code": "Код",
|
|
||||||
"Comment": "Коментар",
|
|
||||||
"Text": "Текст",
|
|
||||||
"Heading 1": "Заголовок 1",
|
|
||||||
"Heading 2": "Заголовок 2",
|
|
||||||
"Heading 3": "Заголовок 3",
|
|
||||||
"To-do List": "Список справ",
|
|
||||||
"Bullet List": "Маркований список",
|
|
||||||
"Numbered List": "Нумерований список",
|
|
||||||
"Blockquote": "Блок цитування",
|
|
||||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
|
||||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
|
||||||
"Big section heading.": "Великий заголовок розділу.",
|
|
||||||
"Medium section heading.": "Середній заголовок розділу.",
|
|
||||||
"Small section heading.": "Малий заголовок розділу.",
|
|
||||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
|
||||||
"Create a list with numbering.": "Створити нумерований список.",
|
|
||||||
"Create block quote.": "Створити блок цитування.",
|
|
||||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
|
||||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
|
||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
|
||||||
"Table": "Таблиця",
|
|
||||||
"Insert a table.": "Вставити таблицю.",
|
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
|
||||||
"Video": "Відео",
|
|
||||||
"Divider": "Роздільник",
|
|
||||||
"Quote": "Цитата",
|
|
||||||
"Image": "Зображення",
|
|
||||||
"File attachment": "Прикріплений файл",
|
|
||||||
"Toggle block": "Блок, що згортається",
|
|
||||||
"Callout": "Виноска",
|
|
||||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
|
||||||
"Math inline": "Формула",
|
|
||||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
|
||||||
"Math block": "Блок формул",
|
|
||||||
"Insert math equation": "Вставити математичне рівняння",
|
|
||||||
"Mermaid diagram": "Діаграма Mermaid",
|
|
||||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
|
||||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
|
||||||
"Insert current date": "Вставити поточну дату",
|
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
|
||||||
"Multiple": "Декілька",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
|
||||||
"Toggle title": "Перемкнути заголовок",
|
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
|
||||||
"Names do not match": "Назви не співпадають",
|
|
||||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
|
||||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
|
||||||
"Space created successfully": "Простір успішно створено",
|
|
||||||
"Space updated successfully": "Простір успішно оновлено",
|
|
||||||
"Space deleted successfully": "Простір успішно видалено",
|
|
||||||
"Members added successfully": "Учасників успішно додано",
|
|
||||||
"Member removed successfully": "Учасника успішно видалено",
|
|
||||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
|
||||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
|
||||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
|
||||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
|
||||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
|
||||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
|
||||||
"New update": "Нове оновлення",
|
|
||||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
|
||||||
"Delete member": "Видалити учасника",
|
|
||||||
"Member deleted successfully": "Учасника успішно видалено",
|
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
|
||||||
"Move": "Перемістити",
|
|
||||||
"Move page": "Перемістити сторінку",
|
|
||||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
|
||||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
|
||||||
"Table of contents": "Зміст",
|
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
|
||||||
"Share": "Поділитися",
|
|
||||||
"Public sharing": "Публічний доступ",
|
|
||||||
"Shared by": "Поділився",
|
|
||||||
"Shared at": "Поділився в",
|
|
||||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
|
||||||
"Share to web": "Поділитися в інтернеті",
|
|
||||||
"Shared to web": "Розміщено в інтернеті",
|
|
||||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
|
||||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
|
||||||
"Include sub-pages": "Включити підсторінки",
|
|
||||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
|
||||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
|
||||||
"Open page": "Відкрити сторінку",
|
|
||||||
"Page": "Сторінка",
|
|
||||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
|
||||||
"Delete share": "Видалити спільний доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
|
||||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
|
||||||
"Share not found": "Спільний доступ не знайдено",
|
|
||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
|
||||||
"Copy page": "Копіювати сторінки",
|
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано"
|
|
||||||
}
|
|
||||||
@ -383,8 +383,5 @@
|
|||||||
"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,12 +29,8 @@ 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();
|
||||||
@ -49,11 +45,6 @@ export default function App() {
|
|||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
|
||||||
<Route
|
|
||||||
path={"/login/mfa/setup"}
|
|
||||||
element={<MfaSetupRequiredPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isCloud() && (
|
{!isCloud() && (
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
@ -67,10 +58,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
<Route element={<ShareLayout />}>
|
||||||
<Route
|
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||||
path={"/share/:shareId/p/:pageSlug"}
|
|
||||||
element={<SharedPage />}
|
|
||||||
/>
|
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@ -79,9 +67,7 @@ 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={
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import { Group, Text } from "@mantine/core";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import React from "react";
|
|
||||||
import { User } from "server/dist/database/types/entity.types";
|
|
||||||
|
|
||||||
interface UserInfoProps {
|
|
||||||
user: User;
|
|
||||||
size?: string;
|
|
||||||
}
|
|
||||||
export function UserInfo({ user, size }: UserInfoProps) {
|
|
||||||
return (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{user?.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
{user?.email}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -27,8 +27,6 @@ export function AppHeader() {
|
|||||||
const { isTrial, trialDaysLeft } = useTrial();
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
@ -40,7 +38,7 @@ export function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
@ -18,7 +18,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentListWithTabs />;
|
component = <CommentList />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@ -38,17 +38,13 @@ export default function Aside() {
|
|||||||
{t(title)}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{tab === "comments" ? (
|
<ScrollArea
|
||||||
<CommentListWithTabs />
|
style={{ height: "85vh" }}
|
||||||
) : (
|
scrollbarSize={5}
|
||||||
<ScrollArea
|
type="scroll"
|
||||||
style={{ height: "85vh" }}
|
>
|
||||||
scrollbarSize={5}
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
type="scroll"
|
</ScrollArea>
|
||||||
>
|
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -73,15 +73,13 @@ export default function GlobalAppShell({
|
|||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={
|
||||||
!hideSidebar && {
|
!isHomeRoute && {
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
@ -102,7 +100,7 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
@ -10,7 +8,6 @@ export default function Layout() {
|
|||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,9 @@
|
|||||||
|
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Group,
|
|
||||||
Menu,
|
|
||||||
UnstyledButton,
|
|
||||||
Text,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrightnessFilled,
|
|
||||||
IconBrush,
|
IconBrush,
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
|
||||||
IconDeviceDesktop,
|
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSun,
|
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@ -31,7 +19,6 @@ export default function TopMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
|
||||||
|
|
||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
@ -88,7 +75,7 @@ export default function TopMenu() {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ width: 190 }}>
|
<div style={{width: 190}}>
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</Text>
|
</Text>
|
||||||
@ -114,44 +101,6 @@ export default function TopMenu() {
|
|||||||
{t("My preferences")}
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Sub>
|
|
||||||
<Menu.Sub.Target>
|
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
|
||||||
{t("Theme")}
|
|
||||||
</Menu.Sub.Item>
|
|
||||||
</Menu.Sub.Target>
|
|
||||||
|
|
||||||
<Menu.Sub.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("light")}
|
|
||||||
leftSection={<IconSun size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Light")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("dark")}
|
|
||||||
leftSection={<IconMoon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Dark")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("auto")}
|
|
||||||
leftSection={<IconDeviceDesktop size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("System settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Sub.Dropdown>
|
|
||||||
</Menu.Sub>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
|||||||
>
|
>
|
||||||
Plan
|
Plan
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="lg" tt="capitalize">
|
<Text fw={700} fz="lg">
|
||||||
{plans.find(
|
{
|
||||||
(plan) => plan.productId === billing.stripeProductId,
|
plans.find(
|
||||||
)?.name ||
|
(plan) => plan.productId === billing.stripeProductId,
|
||||||
billing.planName ||
|
)?.name
|
||||||
"Standard"}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -112,59 +112,18 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Cost
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} fz="lg">
|
||||||
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
|
{billing.currency.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" fz="sm">
|
||||||
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
</Text>
|
</Text>
|
||||||
{billing.billingScheme === "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
|
||||||
{billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
per {billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billing.billingScheme !== "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
|
||||||
{billing.currency.toUpperCase()} / {billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Current Tier
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
For {billing.tieredUpTo} users
|
|
||||||
</Text>
|
|
||||||
{/*billing.tieredFlatAmount && (
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
</Text>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,32 +2,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
List,
|
List,
|
||||||
|
SegmentedControl,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Select,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
Switch,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
import { useAtomValue } from "jotai";
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
|
|
||||||
export default function BillingPlans() {
|
export default function BillingPlans() {
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const [interval, setInterval] = useState("yearly");
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
if (!plans) {
|
||||||
null,
|
return null;
|
||||||
);
|
}
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
const handleCheckout = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -40,194 +32,84 @@ export default function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: remove by July 30.
|
|
||||||
// Check if workspace was created between June 28 and July 14, 2025
|
|
||||||
const showTieredPricingNotice = (() => {
|
|
||||||
if (!workspace?.createdAt) return false;
|
|
||||||
const createdDate = new Date(workspace.createdAt);
|
|
||||||
const startDate = new Date('2025-06-20');
|
|
||||||
const endDate = new Date('2025-07-14');
|
|
||||||
return createdDate >= startDate && createdDate <= endDate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any plan is tiered
|
|
||||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
|
|
||||||
// Set initial tier value if not set and we have tiered plans
|
|
||||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
|
||||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tiered plans, ensure we have a selected tier
|
|
||||||
if (hasTieredPlans && !selectedTierValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectData = firstTieredPlan?.pricingTiers
|
|
||||||
?.filter((tier) => !tier.custom)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const prevMaxUsers =
|
|
||||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
|
||||||
return {
|
|
||||||
value: tier.upTo.toString(),
|
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Group justify="center" p="xl">
|
||||||
{/* Tiered pricing notice for eligible workspaces */}
|
{plans.map((plan) => {
|
||||||
{showTieredPricingNotice && !hasTieredPlans && (
|
const price =
|
||||||
<Alert
|
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||||
icon={<IconInfoCircle size={16} />}
|
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||||
title="Want the old tiered pricing?"
|
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||||
color="blue"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
Contact support to switch back to our tiered pricing model.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
return (
|
||||||
<Stack gap="xl" mb="md">
|
<Card
|
||||||
{/* Team Size and Billing Controls */}
|
key={plan.name}
|
||||||
<Group justify="center" align="center" gap="sm">
|
withBorder
|
||||||
{hasTieredPlans && (
|
radius="md"
|
||||||
<Select
|
shadow="sm"
|
||||||
label="Team size"
|
p="xl"
|
||||||
description="Select the number of users"
|
w={300}
|
||||||
value={selectedTierValue}
|
>
|
||||||
onChange={setSelectedTierValue}
|
<SegmentedControl
|
||||||
data={selectData}
|
value={interval}
|
||||||
w={250}
|
onChange={setInterval}
|
||||||
size="md"
|
fullWidth
|
||||||
allowDeselect={false}
|
data={[
|
||||||
|
{ label: "Monthly", value: "monthly" },
|
||||||
|
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||||
<Flex justify="center" gap="md" align="center">
|
{plan.name}
|
||||||
<Text size="md">Monthly</Text>
|
</Title>
|
||||||
<Switch
|
<Text ta="center" size="lg" fw={700}>
|
||||||
defaultChecked={isAnnual}
|
{interval === "monthly" && (
|
||||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
<>
|
||||||
size="sm"
|
${price}{" "}
|
||||||
/>
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
<Text size="md">
|
/user/month
|
||||||
Annually
|
|
||||||
<Badge component="span" variant="light" color="blue">
|
|
||||||
15% OFF
|
|
||||||
</Badge>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
|
||||||
{plans.map((plan, index) => {
|
|
||||||
let price;
|
|
||||||
let displayPrice;
|
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
|
||||||
|
|
||||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
|
||||||
// Tiered billing logic
|
|
||||||
const planSelectedTier =
|
|
||||||
plan.pricingTiers.find(
|
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || plan.pricingTiers[0];
|
|
||||||
|
|
||||||
price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
|
||||||
} else {
|
|
||||||
// Per-unit billing logic
|
|
||||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
|
||||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
|
||||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
|
||||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.name}
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
p="xl"
|
|
||||||
w={350}
|
|
||||||
miw={300}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Plan Header */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Title order={3} size="h4">
|
|
||||||
{plan.name}
|
|
||||||
</Title>
|
|
||||||
{plan.description && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{plan.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Pricing */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group align="baseline" gap="xs">
|
|
||||||
<Title order={1} size="h1">
|
|
||||||
${displayPrice}
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed">
|
|
||||||
{plan.billingScheme === 'per_unit'
|
|
||||||
? `per user/month`
|
|
||||||
: `per month`}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
|
||||||
</Text>
|
</Text>
|
||||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
</>
|
||||||
<Text size="md" fw={500}>
|
)}
|
||||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
{interval === "yearly" && (
|
||||||
</Text>
|
<>
|
||||||
)}
|
${yearlyMonthPrice}{" "}
|
||||||
</Stack>
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
|
/user/month
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br/>
|
||||||
|
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||||
|
billed {interval}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* CTA Button */}
|
<Card.Section mt="lg">
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
Subscribe
|
Subscribe
|
||||||
</Button>
|
</Button>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
{/* Features */}
|
<Card.Section mt="md">
|
||||||
<List
|
<List
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={
|
center
|
||||||
<ThemeIcon size={20} radius="xl">
|
icon={
|
||||||
<IconCheck size={14} />
|
<ThemeIcon variant="light" size={24} radius="xl">
|
||||||
</ThemeIcon>
|
<IconCheck size={16} />
|
||||||
}
|
</ThemeIcon>
|
||||||
>
|
}
|
||||||
{plan.features.map((feature, featureIndex) => (
|
>
|
||||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
{plan.features.map((feature, index) => (
|
||||||
))}
|
<List.Item key={index}>{feature}</List.Item>
|
||||||
</List>
|
))}
|
||||||
</Stack>
|
</List>
|
||||||
</Card>
|
</Card.Section>
|
||||||
);
|
</Card>
|
||||||
})}
|
);
|
||||||
</Group>
|
})}
|
||||||
</Container>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,11 +25,6 @@ export interface IBilling {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
billingScheme: string | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
tieredFlatAmount: number | null;
|
|
||||||
tieredUnitAmount: number | null;
|
|
||||||
planName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICheckoutLink {
|
export interface ICheckoutLink {
|
||||||
@ -47,18 +42,9 @@ export interface IBillingPlan {
|
|||||||
monthlyId: string;
|
monthlyId: string;
|
||||||
yearlyId: string;
|
yearlyId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
price?: {
|
price: {
|
||||||
monthly: string;
|
monthly: string;
|
||||||
yearly: string;
|
yearly: string;
|
||||||
};
|
};
|
||||||
features: string[];
|
features: string[];
|
||||||
billingScheme: string | null;
|
|
||||||
pricingTiers?: PricingTier[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PricingTier {
|
|
||||||
upTo: number;
|
|
||||||
monthly?: number;
|
|
||||||
yearly?: number;
|
|
||||||
custom?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
|
||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Editor } from "@tiptap/react";
|
|
||||||
|
|
||||||
interface ResolveCommentProps {
|
|
||||||
editor: Editor;
|
|
||||||
commentId: string;
|
|
||||||
pageId: string;
|
|
||||||
resolvedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResolveComment({
|
|
||||||
editor,
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolvedAt,
|
|
||||||
}: ResolveCommentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
|
|
||||||
const isResolved = resolvedAt != null;
|
|
||||||
const iconColor = isResolved ? "green" : "gray";
|
|
||||||
|
|
||||||
const handleResolveToggle = async () => {
|
|
||||||
try {
|
|
||||||
await resolveCommentMutation.mutateAsync({
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolved: !isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to toggle resolved state:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleResolveToggle}
|
|
||||||
variant="subtle"
|
|
||||||
color={isResolved ? "green" : "gray"}
|
|
||||||
size="sm"
|
|
||||||
loading={resolveCommentMutation.isPending}
|
|
||||||
disabled={resolveCommentMutation.isPending}
|
|
||||||
>
|
|
||||||
{isResolved ? (
|
|
||||||
<IconCircleCheckFilled size={18} />
|
|
||||||
) : (
|
|
||||||
<IconCircleCheck size={18} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResolveComment;
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
|
||||||
import {
|
|
||||||
IComment,
|
|
||||||
IResolveComment,
|
|
||||||
} from "@/features/comment/types/comment.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
|
||||||
onMutate: async (variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
|
||||||
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
|
||||||
if (!old || !old.items) return old;
|
|
||||||
const updatedItems = old.items.map((comment) =>
|
|
||||||
comment.id === variables.commentId
|
|
||||||
? {
|
|
||||||
...comment,
|
|
||||||
resolvedAt: variables.resolved ? new Date() : null,
|
|
||||||
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
|
||||||
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
|
||||||
}
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
items: updatedItems,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { previousComments };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousComments) {
|
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
|
||||||
}
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to resolve comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data: IComment, variables) => {
|
|
||||||
const pageId = data.pageId;
|
|
||||||
const currentComments = queryClient.getQueryData(
|
|
||||||
RQ_KEY(pageId),
|
|
||||||
) as IPagination<IComment>;
|
|
||||||
if (currentComments && currentComments.items) {
|
|
||||||
const updatedComments = currentComments.items.map((comment) =>
|
|
||||||
comment.id === variables.commentId
|
|
||||||
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
|
||||||
...currentComments,
|
|
||||||
items: updatedComments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emit({
|
|
||||||
operation: "resolveComment",
|
|
||||||
pageId: pageId,
|
|
||||||
commentId: variables.commentId,
|
|
||||||
resolved: variables.resolved,
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
|
|
||||||
notifications.show({
|
|
||||||
message: variables.resolved
|
|
||||||
? t("Comment resolved successfully")
|
|
||||||
: t("Comment re-opened successfully")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export function PosthogUser() {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
const user = currentUser?.user;
|
|
||||||
const workspace = currentUser?.workspace;
|
|
||||||
if (!user || !workspace) return;
|
|
||||||
|
|
||||||
posthog?.identify(user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
workspaceId: user.workspaceId,
|
|
||||||
workspaceHostname: workspace.hostname,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
posthog?.group("workspace", workspace.id, {
|
|
||||||
name: workspace.name,
|
|
||||||
hostname: workspace.hostname,
|
|
||||||
plan: workspace?.plan,
|
|
||||||
status: workspace.status,
|
|
||||||
isOnTrial: !!workspace.trialEndAt,
|
|
||||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
|
||||||
memberCount: workspace.memberCount,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: workspace.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [posthog, currentUser]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface MfaBackupCodeInputProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onSubmit: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MfaBackupCodeInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
isLoading,
|
|
||||||
}: MfaBackupCodeInputProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Enter one of your backup codes. Each backup code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={t("Backup code")}
|
|
||||||
placeholder="XXXXXXXX"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
|
||||||
error={error}
|
|
||||||
autoFocus
|
|
||||||
maxLength={8}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontFamily: "monospace",
|
|
||||||
letterSpacing: "0.1em",
|
|
||||||
fontSize: "1rem",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
loading={isLoading}
|
|
||||||
onClick={onSubmit}
|
|
||||||
leftSection={<IconKey size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify backup code")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{t("Use authenticator app instead")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Group,
|
|
||||||
List,
|
|
||||||
Code,
|
|
||||||
CopyButton,
|
|
||||||
Alert,
|
|
||||||
PasswordInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconRefresh,
|
|
||||||
IconCopy,
|
|
||||||
IconCheck,
|
|
||||||
IconAlertCircle,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
interface MfaBackupCodesModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaBackupCodesModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
}: MfaBackupCodesModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const regenerateMutation = useMutation({
|
|
||||||
mutationFn: (data: { confirmPassword: string }) =>
|
|
||||||
regenerateBackupCodes(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBackupCodes(data.backupCodes);
|
|
||||||
setShowNewCodes(true);
|
|
||||||
form.reset();
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("New backup codes have been generated"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message:
|
|
||||||
error.response?.data?.message ||
|
|
||||||
t("Failed to regenerate backup codes"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
|
||||||
regenerateMutation.mutate(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setShowNewCodes(false);
|
|
||||||
setBackupCodes([]);
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Backup codes")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{!showNewCodes ? (
|
|
||||||
<form onSubmit={form.onSubmit(handleRegenerate)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("About backup codes")}
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Confirm password")}
|
|
||||||
placeholder={t("Enter your password")}
|
|
||||||
variant="filled"
|
|
||||||
{...form.getInputProps("confirmPassword")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={regenerateMutation.isPending}
|
|
||||||
leftSection={<IconRefresh size={18} />}
|
|
||||||
>
|
|
||||||
{t("Generate new backup codes")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("Save your new backup codes")}
|
|
||||||
color="yellow"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("Your new backup codes")}
|
|
||||||
</Text>
|
|
||||||
<CopyButton value={backupCodes.join("\n")}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={copy}
|
|
||||||
leftSection={
|
|
||||||
copied ? (
|
|
||||||
<IconCheck size={14} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copied ? t("Copied") : t("Copy")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
<List size="sm" spacing="xs">
|
|
||||||
{backupCodes.map((code, index) => (
|
|
||||||
<List.Item key={index}>
|
|
||||||
<Code>{code}</Code>
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
onClick={handleClose}
|
|
||||||
leftSection={<IconCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("I've saved my backup codes")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper {
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: var(--mantine-shadow-lg);
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
PinInput,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Anchor,
|
|
||||||
Paper,
|
|
||||||
Center,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import classes from "./mfa-challenge.module.css";
|
|
||||||
import { verifyMfa } from "@/ee/mfa";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as z from "zod";
|
|
||||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
code: z
|
|
||||||
.string()
|
|
||||||
.refine(
|
|
||||||
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
|
|
||||||
{
|
|
||||||
message: "Enter a 6-digit code or 8-character backup code",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type MfaChallengeFormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function MfaChallenge() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<MfaChallengeFormValues>({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
code: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: MfaChallengeFormValues) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await verifyMfa(values.code);
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
} catch (error: any) {
|
|
||||||
setIsLoading(false);
|
|
||||||
notifications.show({
|
|
||||||
message:
|
|
||||||
error.response?.data?.message || t("Invalid verification code"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size={420} className={classes.container}>
|
|
||||||
<Paper radius="lg" p={40} className={classes.paper}>
|
|
||||||
<Stack align="center" gap="xl">
|
|
||||||
<Center>
|
|
||||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
|
||||||
<IconDeviceMobile size={40} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<Title order={2} ta="center" fw={600}>
|
|
||||||
{t("Two-factor authentication")}
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{useBackupCode
|
|
||||||
? t("Enter one of your backup codes")
|
|
||||||
: t("Enter the 6-digit code found in your authenticator app")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!useBackupCode ? (
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit(handleSubmit)}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Center>
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
autoFocus
|
|
||||||
oneTimeCode
|
|
||||||
{...form.getInputProps("code")}
|
|
||||||
error={!!form.errors.code}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
{form.errors.code && (
|
|
||||||
<Text c="red" size="sm" ta="center">
|
|
||||||
{form.errors.code}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
loading={isLoading}
|
|
||||||
leftSection={<IconLock size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
c="dimmed"
|
|
||||||
onClick={() => {
|
|
||||||
setUseBackupCode(true);
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
form.clearErrors();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Use backup code")}
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<MfaBackupCodeInput
|
|
||||||
value={form.values.code}
|
|
||||||
onChange={(value) => form.setFieldValue("code", value)}
|
|
||||||
error={form.errors.code?.toString()}
|
|
||||||
onSubmit={() => handleSubmit(form.values)}
|
|
||||||
onCancel={() => {
|
|
||||||
setUseBackupCode(false);
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
form.clearErrors();
|
|
||||||
}}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
PasswordInput,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { disableMfa } from "@/ee/mfa";
|
|
||||||
|
|
||||||
interface MfaDisableModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaDisableModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onComplete,
|
|
||||||
}: MfaDisableModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
|
||||||
mutationFn: disableMfa,
|
|
||||||
onSuccess: () => {
|
|
||||||
onComplete();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message: error.response?.data?.message || t("Failed to disable MFA"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
|
||||||
await disableMutation.mutateAsync(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Disable two-factor authentication")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertTriangle size={20} />}
|
|
||||||
title={t("Warning")}
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Please enter your password to disable two-factor authentication:",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Password")}
|
|
||||||
placeholder={t("Enter your password")}
|
|
||||||
{...form.getInputProps("confirmPassword")}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
color="red"
|
|
||||||
loading={disableMutation.isPending}
|
|
||||||
leftSection={<IconShieldOff size={18} />}
|
|
||||||
>
|
|
||||||
{t("Disable two-factor authentication")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={disableMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Group, Text, Button } from "@mantine/core";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getMfaStatus } from "@/ee/mfa";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import { MfaDisableModal } from "@/ee/mfa";
|
|
||||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaSettings() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
|
||||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
|
||||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: mfaStatus, isLoading } = useQuery({
|
|
||||||
queryKey: ["mfa-status"],
|
|
||||||
queryFn: getMfaStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if MFA is truly enabled
|
|
||||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
|
||||||
|
|
||||||
const handleSetupComplete = () => {
|
|
||||||
setSetupModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("Two-factor authentication has been enabled"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisableComplete = () => {
|
|
||||||
setDisableModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("Two-factor authentication has been disabled"),
|
|
||||||
color: "blue",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Text size="md">{t("2-step verification")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{!isMfaEnabled
|
|
||||||
? t(
|
|
||||||
"Protect your account with an additional verification layer when signing in.",
|
|
||||||
)
|
|
||||||
: t("Two-factor authentication is active on your account.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMfaEnabled ? (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setSetupModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Add 2FA method")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setBackupCodesModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
color="red"
|
|
||||||
onClick={() => setDisableModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Disable")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={setupModalOpen}
|
|
||||||
onClose={() => setSetupModalOpen(false)}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MfaDisableModal
|
|
||||||
opened={disableModalOpen}
|
|
||||||
onClose={() => setDisableModalOpen(false)}
|
|
||||||
onComplete={handleDisableComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MfaBackupCodesModal
|
|
||||||
opened={backupCodesModalOpen}
|
|
||||||
onClose={() => setBackupCodesModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stepper,
|
|
||||||
Center,
|
|
||||||
Image,
|
|
||||||
PinInput,
|
|
||||||
Alert,
|
|
||||||
List,
|
|
||||||
CopyButton,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
Paper,
|
|
||||||
Code,
|
|
||||||
Loader,
|
|
||||||
Collapse,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconQrcode,
|
|
||||||
IconShieldCheck,
|
|
||||||
IconKey,
|
|
||||||
IconCopy,
|
|
||||||
IconCheck,
|
|
||||||
IconAlertCircle,
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconPrinter,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
interface MfaSetupModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
isRequired?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetupData {
|
|
||||||
secret: string;
|
|
||||||
qrCode: string;
|
|
||||||
manualKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
verificationCode: z
|
|
||||||
.string()
|
|
||||||
.length(6, { message: "Please enter a 6-digit code" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaSetupModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onComplete,
|
|
||||||
isRequired = false,
|
|
||||||
}: MfaSetupModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
verificationCode: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupMutation = useMutation({
|
|
||||||
mutationFn: () => setupMfa({ method: "totp" }),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setSetupData(data);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message: error.response?.data?.message || t("Failed to setup MFA"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate QR code when modal opens
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (opened && !setupData && !setupMutation.isPending) {
|
|
||||||
setupMutation.mutate();
|
|
||||||
}
|
|
||||||
}, [opened]);
|
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
|
||||||
mutationFn: (verificationCode: string) =>
|
|
||||||
enableMfa({
|
|
||||||
secret: setupData!.secret,
|
|
||||||
verificationCode,
|
|
||||||
}),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBackupCodes(data.backupCodes);
|
|
||||||
setActive(1); // Move to backup codes step
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message:
|
|
||||||
error.response?.data?.message || t("Invalid verification code"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
form.setFieldValue("verificationCode", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (active === 1 && backupCodes.length > 0) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
// Reset state
|
|
||||||
setTimeout(() => {
|
|
||||||
setActive(0);
|
|
||||||
setSetupData(null);
|
|
||||||
setBackupCodes([]);
|
|
||||||
setManualEntryOpen(false);
|
|
||||||
form.reset();
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = async (values: { verificationCode: string }) => {
|
|
||||||
await enableMutation.mutateAsync(values.verificationCode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrintBackupCodes = () => {
|
|
||||||
window.print();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Set up two-factor authentication")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stepper active={active} size="sm">
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Setup & Verify")}
|
|
||||||
description={t("Add to authenticator")}
|
|
||||||
icon={<IconQrcode size={18} />}
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleVerify)}>
|
|
||||||
<Stack gap="md" mt="xl">
|
|
||||||
{setupMutation.isPending ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader size="lg" />
|
|
||||||
</Center>
|
|
||||||
) : setupData ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("1. Scan this QR code with your authenticator app")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Image
|
|
||||||
src={setupData.qrCode}
|
|
||||||
alt="MFA QR Code"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={() => setManualEntryOpen(!manualEntryOpen)}
|
|
||||||
>
|
|
||||||
<Group gap="xs">
|
|
||||||
{manualEntryOpen ? (
|
|
||||||
<IconChevronDown size={16} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight size={16} />
|
|
||||||
)}
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Can't scan the code?")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
|
|
||||||
<Collapse in={manualEntryOpen}>
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm" mb="sm">
|
|
||||||
{t(
|
|
||||||
"Enter this code manually in your authenticator app:",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Code block>{setupData.manualKey}</Code>
|
|
||||||
<CopyButton value={setupData.manualKey}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip label={copied ? t("Copied") : t("Copy")}>
|
|
||||||
<ActionIcon
|
|
||||||
color={copied ? "green" : "gray"}
|
|
||||||
onClick={copy}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<IconCheck size={16} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={16} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Text size="sm" mt="md">
|
|
||||||
{t("2. Enter the 6-digit code from your authenticator")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Stack align="center">
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
autoFocus
|
|
||||||
oneTimeCode
|
|
||||||
{...form.getInputProps("verificationCode")}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{form.errors.verificationCode && (
|
|
||||||
<Text c="red" size="sm">
|
|
||||||
{form.errors.verificationCode}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={enableMutation.isPending}
|
|
||||||
leftSection={<IconShieldCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify and enable")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Center py="xl">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Failed to generate QR code. Please try again.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Stepper.Step>
|
|
||||||
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Backup")}
|
|
||||||
description={t("Save codes")}
|
|
||||||
icon={<IconKey size={18} />}
|
|
||||||
>
|
|
||||||
<Stack gap="md" mt="xl">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("Save your backup codes")}
|
|
||||||
color="yellow"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("Backup codes")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<CopyButton value={backupCodes.join("\n")}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={copy}
|
|
||||||
leftSection={
|
|
||||||
copied ? (
|
|
||||||
<IconCheck size={14} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copied ? t("Copied") : t("Copy")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={handlePrintBackupCodes}
|
|
||||||
leftSection={<IconPrinter size={14} />}
|
|
||||||
>
|
|
||||||
{t("Print")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<List size="sm" spacing="xs">
|
|
||||||
{backupCodes.map((code, index) => (
|
|
||||||
<List.Item key={index}>
|
|
||||||
<Code>{code}</Code>
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
onClick={handleClose}
|
|
||||||
leftSection={<IconCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("I've saved my backup codes")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stepper.Step>
|
|
||||||
</Stepper>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
|
||||||
import { IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export default function MfaSetupRequired() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSetupComplete = () => {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="sm" py="xl">
|
|
||||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
|
||||||
<Stack>
|
|
||||||
<Title order={2} ta="center">
|
|
||||||
{t("Two-factor authentication required")}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Your workspace requires two-factor authentication. Please set it up to continue.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="sm" ta="center">
|
|
||||||
{t(
|
|
||||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={true}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
isRequired={true}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
.qrCodeContainer {
|
|
||||||
background-color: white;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backupCodesList {
|
|
||||||
font-family: var(--mantine-font-family-monospace);
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeItem {
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setupStep {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verificationInput {
|
|
||||||
max-width: 320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { validateMfaAccess } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function useMfaPageProtection() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const [isValidating, setIsValidating] = useState(true);
|
|
||||||
const [isValid, setIsValid] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAccess = async () => {
|
|
||||||
const result = await validateMfaAccess();
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is on the correct page based on their MFA state
|
|
||||||
const isOnChallengePage =
|
|
||||||
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
|
|
||||||
const isOnSetupPage =
|
|
||||||
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
|
|
||||||
|
|
||||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
|
||||||
// User needs to set up MFA but is on challenge page
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
|
||||||
} else if (
|
|
||||||
!result.requiresMfaSetup &&
|
|
||||||
result.userHasMfa &&
|
|
||||||
!isOnChallengePage
|
|
||||||
) {
|
|
||||||
// User has MFA and should be on challenge page
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
|
||||||
} else if (!result.isTransferToken) {
|
|
||||||
// User has a regular auth token, shouldn't be on MFA pages
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
} else {
|
|
||||||
setIsValid(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsValidating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAccess();
|
|
||||||
}, [navigate, location.pathname]);
|
|
||||||
|
|
||||||
return { isValidating, isValid };
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
// Components
|
|
||||||
export { MfaChallenge } from "./components/mfa-challenge";
|
|
||||||
export { MfaSettings } from "./components/mfa-settings";
|
|
||||||
export { MfaSetupModal } from "./components/mfa-setup-modal";
|
|
||||||
export { MfaDisableModal } from "./components/mfa-disable-modal";
|
|
||||||
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
|
|
||||||
|
|
||||||
// Pages
|
|
||||||
export { MfaChallengePage } from "./pages/mfa-challenge-page";
|
|
||||||
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export * from "./services/mfa-service";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export * from "./types/mfa.types";
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { MfaChallenge } from "@/ee/mfa";
|
|
||||||
import { useMfaPageProtection } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaChallengePage() {
|
|
||||||
const { isValid } = useMfaPageProtection();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MfaChallenge />;
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Paper,
|
|
||||||
Alert,
|
|
||||||
Center,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useMfaPageProtection } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaSetupRequiredPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
|
||||||
const { isValid } = useMfaPageProtection();
|
|
||||||
|
|
||||||
const handleSetupComplete = async () => {
|
|
||||||
setSetupModalOpen(false);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t(
|
|
||||||
"Two-factor authentication has been set up. Please log in again.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size={480} className={classes.container}>
|
|
||||||
<Paper radius="lg" p={40}>
|
|
||||||
<Stack align="center" gap="xl">
|
|
||||||
<Center>
|
|
||||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
|
||||||
<IconShieldCheck size={40} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<Title order={2} ta="center" fw={600}>
|
|
||||||
{t("Two-factor authentication required")}
|
|
||||||
</Title>
|
|
||||||
<Text size="md" c="dimmed" ta="center">
|
|
||||||
{t(
|
|
||||||
"Your workspace requires two-factor authentication for all users",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Stack w="100%" gap="sm">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
onClick={() => setSetupModalOpen(true)}
|
|
||||||
leftSection={<IconShieldCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("Set up two-factor authentication")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={handleLogout}
|
|
||||||
>
|
|
||||||
{t("Cancel and logout")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={setupModalOpen}
|
|
||||||
onClose={() => setSetupModalOpen(false)}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
isRequired={true}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
MfaBackupCodesResponse,
|
|
||||||
MfaDisableRequest,
|
|
||||||
MfaEnableRequest,
|
|
||||||
MfaEnableResponse,
|
|
||||||
MfaSetupRequest,
|
|
||||||
MfaSetupResponse,
|
|
||||||
MfaStatusResponse,
|
|
||||||
MfaAccessValidationResponse,
|
|
||||||
} from "@/ee/mfa";
|
|
||||||
|
|
||||||
export async function getMfaStatus(): Promise<MfaStatusResponse> {
|
|
||||||
const req = await api.post("/mfa/status");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setupMfa(
|
|
||||||
data: MfaSetupRequest,
|
|
||||||
): Promise<MfaSetupResponse> {
|
|
||||||
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enableMfa(
|
|
||||||
data: MfaEnableRequest,
|
|
||||||
): Promise<MfaEnableResponse> {
|
|
||||||
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disableMfa(
|
|
||||||
data: MfaDisableRequest,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function regenerateBackupCodes(data: {
|
|
||||||
confirmPassword: string;
|
|
||||||
}): Promise<MfaBackupCodesResponse> {
|
|
||||||
const req = await api.post<MfaBackupCodesResponse>(
|
|
||||||
"/mfa/generate-backup-codes",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyMfa(code: string): Promise<any> {
|
|
||||||
const req = await api.post("/mfa/verify", { code });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
|
|
||||||
try {
|
|
||||||
const res = await api.post("/mfa/validate-access");
|
|
||||||
return res.data;
|
|
||||||
} catch {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
export interface MfaMethod {
|
|
||||||
type: 'totp' | 'email';
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSettings {
|
|
||||||
isEnabled: boolean;
|
|
||||||
methods: MfaMethod[];
|
|
||||||
backupCodesCount: number;
|
|
||||||
lastUpdated?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupState {
|
|
||||||
method: 'totp' | 'email';
|
|
||||||
secret?: string;
|
|
||||||
qrCode?: string;
|
|
||||||
manualEntry?: string;
|
|
||||||
backupCodes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaStatusResponse {
|
|
||||||
isEnabled?: boolean;
|
|
||||||
method?: string | null;
|
|
||||||
backupCodesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupRequest {
|
|
||||||
method: 'totp';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupResponse {
|
|
||||||
method: string;
|
|
||||||
qrCode: string;
|
|
||||||
secret: string;
|
|
||||||
manualKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaEnableRequest {
|
|
||||||
secret: string;
|
|
||||||
verificationCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaEnableResponse {
|
|
||||||
success: boolean;
|
|
||||||
backupCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaDisableRequest {
|
|
||||||
confirmPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaBackupCodesResponse {
|
|
||||||
backupCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaAccessValidationResponse {
|
|
||||||
valid: boolean;
|
|
||||||
isTransferToken?: boolean;
|
|
||||||
requiresMfaSetup?: boolean;
|
|
||||||
userHasMfa?: boolean;
|
|
||||||
isMfaEnforced?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
|
|
||||||
export default function EnforceMfa() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title order={4} my="sm">
|
|
||||||
MFA
|
|
||||||
</Title>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnforceMfaToggle />
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnforceMfaToggleProps {
|
|
||||||
size?: MantineSize;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
size={size}
|
|
||||||
label={label}
|
|
||||||
labelPosition="left"
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
aria-label={t("Toggle MFA enforcement")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ 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 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();
|
||||||
@ -34,10 +33,6 @@ 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>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -11,7 +11,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { IRegister } from "@/features/auth/types/auth.types";
|
import { IRegister } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
@ -19,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
|
|||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -73,43 +71,39 @@ export function InviteSignUpForm() {
|
|||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<SsoLogin />
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
label={t("Name")}
|
||||||
|
placeholder={t("enter your full name")}
|
||||||
|
variant="filled"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
{!invitation.enforceSso && (
|
<TextInput
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
id="email"
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
type="email"
|
||||||
<TextInput
|
label={t("Email")}
|
||||||
id="name"
|
value={invitation.email}
|
||||||
type="text"
|
disabled
|
||||||
label={t("Name")}
|
variant="filled"
|
||||||
placeholder={t("enter your full name")}
|
mt="md"
|
||||||
variant="filled"
|
/>
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<PasswordInput
|
||||||
id="email"
|
label={t("Password")}
|
||||||
type="email"
|
placeholder={t("Your password")}
|
||||||
label={t("Email")}
|
variant="filled"
|
||||||
value={invitation.email}
|
mt="md"
|
||||||
disabled
|
{...form.getInputProps("password")}
|
||||||
variant="filled"
|
/>
|
||||||
mt="md"
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
/>
|
{t("Sign Up")}
|
||||||
|
</Button>
|
||||||
<PasswordInput
|
</form>
|
||||||
label={t("Password")}
|
</Stack>
|
||||||
placeholder={t("Your password")}
|
|
||||||
variant="filled"
|
|
||||||
mt="md"
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
|
||||||
{t("Sign Up")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().max(50).optional(),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -60,17 +60,15 @@ export function SetupWorkspaceForm() {
|
|||||||
{isCloud() && <SsoCloudSignup />}
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
{!isCloud() && (
|
<TextInput
|
||||||
<TextInput
|
id="workspaceName"
|
||||||
id="workspaceName"
|
type="text"
|
||||||
type="text"
|
label={t("Workspace Name")}
|
||||||
label={t("Workspace Name")}
|
placeholder={t("e.g ACME Inc")}
|
||||||
placeholder={t("e.g ACME Inc")}
|
variant="filled"
|
||||||
variant="filled"
|
mt="md"
|
||||||
mt="md"
|
{...form.getInputProps("workspaceName")}
|
||||||
{...form.getInputProps("workspaceName")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
|||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -39,17 +39,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
// Check if MFA is required
|
|
||||||
if (response?.userHasMfa) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
|
||||||
} else if (response?.requiresMfaSetup) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@ -64,19 +56,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await acceptInvitation(data);
|
await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
|
||||||
notifications.show({
|
|
||||||
message: t(
|
|
||||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@ -118,22 +100,12 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await passwordReset(data);
|
await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
notifications.show({
|
||||||
notifications.show({
|
message: t("Password reset was successful"),
|
||||||
message: t(
|
});
|
||||||
"Password reset was successful. Please log in with your new password.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Password reset was successful"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@ -4,16 +4,14 @@ import {
|
|||||||
ICollabToken,
|
ICollabToken,
|
||||||
IForgotPassword,
|
IForgotPassword,
|
||||||
ILogin,
|
ILogin,
|
||||||
ILoginResponse,
|
|
||||||
IPasswordReset,
|
IPasswordReset,
|
||||||
ISetupWorkspace,
|
ISetupWorkspace,
|
||||||
IVerifyUserToken,
|
IVerifyUserToken,
|
||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
|
||||||
export async function login(data: ILogin): Promise<ILoginResponse> {
|
export async function login(data: ILogin): Promise<void> {
|
||||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
await api.post<void>("/auth/login", data);
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
@ -38,9 +36,8 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
|||||||
await api.post<void>("/auth/forgot-password", data);
|
await api.post<void>("/auth/forgot-password", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||||
const req = await api.post("/auth/password-reset", data);
|
await api.post<void>("/auth/password-reset", data);
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
@ -50,4 +47,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,12 +36,5 @@ export interface IVerifyUserToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollabToken {
|
export interface ICollabToken {
|
||||||
token?: string;
|
token: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILoginResponse {
|
|
||||||
userHasMfa?: boolean;
|
|
||||||
requiresMfaSetup?: boolean;
|
|
||||||
mfaToken?: string;
|
|
||||||
isMfaEnforced?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import React, { useEffect, 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";
|
||||||
@ -7,34 +7,22 @@ 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 { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
|
||||||
userSpaceRole?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentListItem({
|
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||||
comment,
|
|
||||||
pageId,
|
|
||||||
canComment,
|
|
||||||
userSpaceRole,
|
|
||||||
}: CommentListItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -42,13 +30,11 @@ function CommentListItem({
|
|||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content);
|
setContent(comment.content)
|
||||||
}, [comment]);
|
}, [comment]);
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
@ -86,35 +72,8 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
||||||
);
|
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
el.classList.add("comment-highlight");
|
el.classList.add("comment-highlight");
|
||||||
@ -147,42 +106,28 @@ function CommentListItem({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{!comment.parentCommentId && canComment && isCloudEE && (
|
{/*!comment.parentCommentId && (
|
||||||
<ResolveComment
|
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||||
editor={editor}
|
)*/}
|
||||||
commentId={comment.id}
|
|
||||||
pageId={comment.pageId}
|
|
||||||
resolvedAt={comment.resolvedAt}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{currentUser?.user?.id === comment.creatorId && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
onResolveComment={handleResolveComment}
|
|
||||||
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
||||||
isResolved={comment.resolvedAt != null}
|
|
||||||
isParentComment={!comment.parentCommentId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
{timeAgo(comment.createdAt)}
|
||||||
{timeAgo(comment.createdAt)}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box
|
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||||
className={classes.textSelection}
|
|
||||||
onClick={() => handleCommentClick(comment)}
|
|
||||||
>
|
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,318 +0,0 @@
|
|||||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
|
||||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
|
||||||
import {
|
|
||||||
useCommentsQuery,
|
|
||||||
useCreateCommentMutation,
|
|
||||||
} from "@/features/comment/queries/comment-query";
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
function CommentListWithTabs() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
|
||||||
const {
|
|
||||||
data: comments,
|
|
||||||
isLoading: isCommentsLoading,
|
|
||||||
isError,
|
|
||||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
const canComment: boolean = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate active and resolved comments
|
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
|
||||||
if (!comments?.items) {
|
|
||||||
return { activeComments: [], resolvedComments: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentComments = comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === null
|
|
||||||
);
|
|
||||||
|
|
||||||
const active = parentComments.filter(
|
|
||||||
(comment: IComment) => !comment.resolvedAt
|
|
||||||
);
|
|
||||||
const resolved = parentComments.filter(
|
|
||||||
(comment: IComment) => comment.resolvedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
|
||||||
}, [comments]);
|
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
|
||||||
async (commentId: string, content: string) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
|
||||||
pageId: page?.id,
|
|
||||||
parentCommentId: commentId,
|
|
||||||
content: JSON.stringify(content),
|
|
||||||
};
|
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: page?.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to post comment:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createCommentMutation, page?.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderComments = useCallback(
|
|
||||||
(comment: IComment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={comment.id}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!comment.resolvedAt && canComment && (
|
|
||||||
<>
|
|
||||||
<Divider my={4} />
|
|
||||||
<CommentEditorWithActions
|
|
||||||
commentId={comment.id}
|
|
||||||
onSave={handleAddReply}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
),
|
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <div>{t("Error loading comments.")}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalComments = activeComments.length + resolvedComments.length;
|
|
||||||
|
|
||||||
// If not cloud/enterprise, show simple list without tabs
|
|
||||||
if (!isCloudEE) {
|
|
||||||
if (totalComments === 0) {
|
|
||||||
return <>{t("No comments yet.")}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
|
||||||
{comments?.items
|
|
||||||
.filter((comment: IComment) => comment.parentCommentId === null)
|
|
||||||
.map((comment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={comment.id}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
|
||||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
|
||||||
<Tabs.List justify="center">
|
|
||||||
<Tabs.Tab
|
|
||||||
value="open"
|
|
||||||
leftSection={
|
|
||||||
<Badge size="sm" variant="light" color="blue">
|
|
||||||
{activeComments.length}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Open")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab
|
|
||||||
value="resolved"
|
|
||||||
leftSection={
|
|
||||||
<Badge size="sm" variant="light" color="green">
|
|
||||||
{resolvedComments.length}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Resolved")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<ScrollArea
|
|
||||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
|
||||||
scrollbarSize={5}
|
|
||||||
type="scroll"
|
|
||||||
>
|
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
|
||||||
<Tabs.Panel value="open" pt="xs">
|
|
||||||
{activeComments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
{t("No open comments.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
activeComments.map(renderComments)
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="resolved" pt="xs">
|
|
||||||
{resolvedComments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
{t("No resolved comments.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
resolvedComments.map(renderComments)
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
|
||||||
comments: IPagination<IComment>;
|
|
||||||
parentId: string;
|
|
||||||
pageId: string;
|
|
||||||
canComment: boolean;
|
|
||||||
userSpaceRole?: string;
|
|
||||||
}
|
|
||||||
const ChildComments = ({
|
|
||||||
comments,
|
|
||||||
parentId,
|
|
||||||
pageId,
|
|
||||||
canComment,
|
|
||||||
userSpaceRole,
|
|
||||||
}: ChildCommentsProps) => {
|
|
||||||
const getChildComments = useCallback(
|
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId
|
|
||||||
),
|
|
||||||
[comments.items]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{getChildComments(parentId).map((childComment) => (
|
|
||||||
<div key={childComment.id}>
|
|
||||||
<CommentListItem
|
|
||||||
comment={childComment}
|
|
||||||
pageId={pageId}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={userSpaceRole}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={childComment.id}
|
|
||||||
pageId={pageId}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={userSpaceRole}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
|
||||||
|
|
||||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const { ref, focused } = useFocusWithin();
|
|
||||||
const commentEditorRef = useRef(null);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
|
||||||
commentEditorRef.current?.clearContent();
|
|
||||||
}, [commentId, content, onSave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
<CommentEditor
|
|
||||||
ref={commentEditorRef}
|
|
||||||
onUpdate={setContent}
|
|
||||||
onSave={handleSave}
|
|
||||||
editable={true}
|
|
||||||
/>
|
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentListWithTabs;
|
|
||||||
162
apps/client/src/features/comment/components/comment-list.tsx
Normal file
162
apps/client/src/features/comment/components/comment-list.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
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";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
|
||||||
|
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 emit = useQueryEmit();
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.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;
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
const ChildComments = ({ comments, parentId, pageId }: 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} />
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={childComment.id}
|
||||||
|
pageId={pageId}
|
||||||
|
/>
|
||||||
|
</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 CommentList;
|
||||||
@ -1,28 +1,15 @@
|
|||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
|
|
||||||
type CommentMenuProps = {
|
type CommentMenuProps = {
|
||||||
onEditComment: () => void;
|
onEditComment: () => void;
|
||||||
onDeleteComment: () => void;
|
onDeleteComment: () => void;
|
||||||
onResolveComment?: () => void;
|
|
||||||
canEdit?: boolean;
|
|
||||||
isResolved?: boolean;
|
|
||||||
isParentComment?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommentMenu({
|
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||||
onEditComment,
|
|
||||||
onDeleteComment,
|
|
||||||
onResolveComment,
|
|
||||||
canEdit = true,
|
|
||||||
isResolved = false,
|
|
||||||
isParentComment = false
|
|
||||||
}: CommentMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
@ -43,34 +30,9 @@ function CommentMenu({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{canEdit && (
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
{t("Edit comment")}
|
||||||
{t("Edit comment")}
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{isParentComment && (
|
|
||||||
isCloudEE ? (
|
|
||||||
<Menu.Item
|
|
||||||
onClick={onResolveComment}
|
|
||||||
leftSection={
|
|
||||||
isResolved ?
|
|
||||||
<IconCircleCheckFilled size={14} /> :
|
|
||||||
<IconCircleCheck size={14} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
<Tooltip label={t("Available in enterprise edition")} position="left">
|
|
||||||
<Menu.Item
|
|
||||||
disabled
|
|
||||||
leftSection={<IconCircleCheck size={14} />}
|
|
||||||
>
|
|
||||||
{t("Resolve comment")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconTrash size={14} />}
|
leftSection={<IconTrash size={14} />}
|
||||||
onClick={openDeleteModal}
|
onClick={openDeleteModal}
|
||||||
|
|||||||
@ -12,12 +12,6 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
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 {
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconCircleCheck } from "@tabler/icons-react";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
|
||||||
|
const isResolved = resolvedAt != null;
|
||||||
|
const iconColor = isResolved ? "green" : "gray";
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
const openConfirmModal = () =>
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Are you sure you want to resolve this comment thread?"),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
|
onConfirm: handleResolveToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResolveToggle = async () => {
|
||||||
|
try {
|
||||||
|
await resolveCommentMutation.mutateAsync({
|
||||||
|
commentId,
|
||||||
|
resolved: !isResolved,
|
||||||
|
});
|
||||||
|
//TODO: remove comment mark
|
||||||
|
// Remove comment thread from state on resolve
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle resolved state:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={openConfirmModal}
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolveComment;
|
||||||
@ -8,11 +8,13 @@ import {
|
|||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
} from "@/features/comment/services/comment-service";
|
} from "@/features/comment/services/comment-service";
|
||||||
import {
|
import {
|
||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
|
IResolveComment,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@ -106,4 +108,34 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
export function useResolveCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
|
onSuccess: (data: IComment, variables) => {
|
||||||
|
const currentComments = queryClient.getQueryData(
|
||||||
|
RQ_KEY(data.pageId),
|
||||||
|
) as IComment[];
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (currentComments) {
|
||||||
|
const updatedComments = currentComments.map((comment) =>
|
||||||
|
comment.id === variables.commentId
|
||||||
|
? { ...comment, ...data }
|
||||||
|
: comment,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
notifications.show({ message: t("Comment resolved successfully") });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to resolve comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ export interface IComment {
|
|||||||
editedAt?: Date;
|
editedAt?: Date;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
@ -29,7 +28,6 @@ export interface ICommentData {
|
|||||||
|
|
||||||
export interface IResolveComment {
|
export interface IResolveComment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
pageId: string;
|
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,12 +116,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onCreate: (instance) => {
|
|
||||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@ -183,8 +177,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
<LinkSelector
|
<LinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={(value) => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(value);
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|||||||
@ -156,11 +156,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (name === "Default") {
|
editor.commands.unsetColor();
|
||||||
editor.commands.unsetColor();
|
name !== "Default" &&
|
||||||
} else {
|
editor
|
||||||
editor.chain().focus().setColor(color || "").run();
|
.chain()
|
||||||
}
|
.focus()
|
||||||
|
.setColor(color || "")
|
||||||
|
.run();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing {
|
|
||||||
user-select: none;
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 10;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeHandleBottom {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 24px;
|
|
||||||
cursor: ns-resize;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 2;
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.05)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper:hover .resizeHandleBottom,
|
|
||||||
.resizing .resizeHandleBottom {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeBar {
|
|
||||||
width: 50px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-gray-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeHandleBottom:hover .resizeBar,
|
|
||||||
.resizing .resizeBar {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-gray-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import classes from "./resizable-wrapper.module.css";
|
|
||||||
|
|
||||||
interface ResizableWrapperProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialHeight?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
onResize?: (height: number) => void;
|
|
||||||
isEditable?: boolean;
|
|
||||||
className?: string;
|
|
||||||
showHandles?: "always" | "hover";
|
|
||||||
direction?: "vertical" | "horizontal" | "both";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
|
||||||
children,
|
|
||||||
initialHeight = 480,
|
|
||||||
minHeight = 200,
|
|
||||||
maxHeight = 1200,
|
|
||||||
onResize,
|
|
||||||
isEditable = true,
|
|
||||||
className,
|
|
||||||
showHandles = "hover",
|
|
||||||
direction = "vertical",
|
|
||||||
}) => {
|
|
||||||
const [resizeParams, setResizeParams] = useState<{
|
|
||||||
initialSize: number;
|
|
||||||
initialClientY: number;
|
|
||||||
initialClientX: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!resizeParams) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!wrapperRef.current) return;
|
|
||||||
|
|
||||||
if (direction === "vertical" || direction === "both") {
|
|
||||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
|
||||||
const newHeight = Math.min(
|
|
||||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
|
||||||
maxHeight
|
|
||||||
);
|
|
||||||
setCurrentHeight(newHeight);
|
|
||||||
wrapperRef.current.style.height = `${newHeight}px`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setResizeParams(null);
|
|
||||||
if (onResize && currentHeight !== initialHeight) {
|
|
||||||
onResize(currentHeight);
|
|
||||||
}
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
|
||||||
|
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
setResizeParams({
|
|
||||||
initialSize: currentHeight,
|
|
||||||
initialClientY: e.clientY,
|
|
||||||
initialClientX: e.clientX,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.style.cursor = "ns-resize";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
}, [currentHeight]);
|
|
||||||
|
|
||||||
const shouldShowHandles =
|
|
||||||
isEditable &&
|
|
||||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className={clsx(classes.wrapper, className, {
|
|
||||||
[classes.resizing]: !!resizeParams,
|
|
||||||
})}
|
|
||||||
style={{ height: currentHeight }}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{!!resizeParams && <div className={classes.overlay} />}
|
|
||||||
{shouldShowHandles && direction === "vertical" && (
|
|
||||||
<div
|
|
||||||
className={classes.resizeHandleBottom}
|
|
||||||
onMouseDown={handleResizeStart}
|
|
||||||
>
|
|
||||||
<div className={classes.resizeBar} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
.embedWrapper {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.embedIframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import React, { useMemo, useCallback } from "react";
|
import { useMemo } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
AspectRatio,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@ -13,18 +14,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
sanitizeUrl,
|
|
||||||
} from "@docmost/editor-ext";
|
} 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
|
||||||
@ -35,8 +32,8 @@ const schema = z.object({
|
|||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes } = props;
|
||||||
const { src, provider, height: nodeHeight } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@ -52,26 +49,15 @@ 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") {
|
if (embedProvider.id === "iframe") {
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
updateAttributes({ src: data.url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (embedProvider.regex.test(data.url)) {
|
if (embedProvider.regex.test(data.url)) {
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Invalid {{provider}} embed link", {
|
message: t("Invalid {{provider}} embed link", {
|
||||||
@ -87,33 +73,19 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<ResizableWrapper
|
<>
|
||||||
initialHeight={nodeHeight || 480}
|
<AspectRatio ratio={16 / 9}>
|
||||||
minHeight={200}
|
<iframe
|
||||||
maxHeight={1200}
|
src={embedUrl}
|
||||||
onResize={handleResize}
|
allow="encrypted-media"
|
||||||
isEditable={editor.isEditable}
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
className={clsx(classes.embedWrapper, {
|
allowFullScreen
|
||||||
"ProseMirror-selectednode": selected,
|
frameBorder="0"
|
||||||
})}
|
></iframe>
|
||||||
>
|
</AspectRatio>
|
||||||
<iframe
|
</>
|
||||||
className={classes.embedIframe}
|
|
||||||
src={sanitizeUrl(embedUrl)}
|
|
||||||
allow="encrypted-media"
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
||||||
allowFullScreen
|
|
||||||
frameBorder="0"
|
|
||||||
/>
|
|
||||||
</ResizableWrapper>
|
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||||
width={300}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
shadow="md"
|
|
||||||
disabled={!editor.isEditable}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type SearchAndReplaceAtomType = {
|
|
||||||
isOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
|
||||||
isOpen: false,
|
|
||||||
});
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
Flex,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconArrowNarrowDown,
|
|
||||||
IconArrowNarrowUp,
|
|
||||||
IconLetterCase,
|
|
||||||
IconReplace,
|
|
||||||
IconSearch,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import classes from "./search-replace.module.css";
|
|
||||||
|
|
||||||
interface PageFindDialogDialogProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [replaceText, setReplaceText] = useState("");
|
|
||||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
|
||||||
{ isReplaceShow: false, color: "gray" },
|
|
||||||
{ isReplaceShow: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
|
||||||
{ isCaseSensitive: false, color: "gray" },
|
|
||||||
{ isCaseSensitive: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setReplaceText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setSearchText("");
|
|
||||||
setReplaceText("");
|
|
||||||
setPageFindState({ isOpen: false });
|
|
||||||
// Reset replace button state when closing
|
|
||||||
if (replaceButton.isReplaceShow) {
|
|
||||||
replaceButtonToggle();
|
|
||||||
}
|
|
||||||
// Clear search term in editor
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setSearchTerm("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToSelection = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
const position: Range = results[resultIndex];
|
|
||||||
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
editor.commands.setTextSelection(position);
|
|
||||||
|
|
||||||
const element = document.querySelector(".search-result-current");
|
|
||||||
if (element)
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
|
|
||||||
editor.commands.setTextSelection(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
editor.commands.nextSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
editor.commands.previousSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replace = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replace();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceAll = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replaceAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setSearchTerm(searchText);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
editor.commands.selectCurrentItem();
|
|
||||||
}, [searchText]);
|
|
||||||
|
|
||||||
const handleOpenEvent = (e) => {
|
|
||||||
setPageFindState({ isOpen: true });
|
|
||||||
const selectedText = editor.state.doc.textBetween(
|
|
||||||
editor.state.selection.from,
|
|
||||||
editor.state.selection.to,
|
|
||||||
);
|
|
||||||
if (selectedText !== "") {
|
|
||||||
setSearchText(selectedText);
|
|
||||||
}
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEvent = (e) => {
|
|
||||||
closeDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
!pageFindState.isOpen && closeDialog();
|
|
||||||
|
|
||||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.removeEventListener(
|
|
||||||
"closeFindDialogFromEditor",
|
|
||||||
handleCloseEvent,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [pageFindState.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
goToSelection();
|
|
||||||
}, [caseSensitive]);
|
|
||||||
|
|
||||||
const resultsCount = useMemo(
|
|
||||||
() =>
|
|
||||||
searchText.trim() === ""
|
|
||||||
? ""
|
|
||||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
|
||||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
|
||||||
1 +
|
|
||||||
"/" +
|
|
||||||
editor?.storage?.searchAndReplace?.results.length
|
|
||||||
: t("Not found"),
|
|
||||||
[
|
|
||||||
searchText,
|
|
||||||
editor?.storage?.searchAndReplace?.resultIndex,
|
|
||||||
editor?.storage?.searchAndReplace?.results.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
closeDialog();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
className={classes.findDialog}
|
|
||||||
opened={pageFindState.isOpen}
|
|
||||||
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={"auto"}
|
|
||||||
position={{ top: 90, right: 50 }}
|
|
||||||
withBorder
|
|
||||||
transitionProps={{ transition: "slide-down" }}
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={t("Find")}
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" ta="right">
|
|
||||||
{resultsCount}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
rightSectionWidth="70"
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={220}
|
|
||||||
onChange={searchInputEvent}
|
|
||||||
value={searchText}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", next],
|
|
||||||
["shift+Enter", previous],
|
|
||||||
["alt+C", caseSensitiveToggle],
|
|
||||||
//@ts-ignore
|
|
||||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
|
||||||
<IconArrowNarrowUp
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Next match (Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
|
||||||
<IconArrowNarrowDown
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Match case (Alt+C)")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={caseSensitive.color}
|
|
||||||
onClick={() => caseSensitiveToggle()}
|
|
||||||
>
|
|
||||||
<IconLetterCase
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{editable && (
|
|
||||||
<Tooltip label={t("Replace")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={replaceButton.color}
|
|
||||||
onClick={() => replaceButtonToggle()}
|
|
||||||
>
|
|
||||||
<IconReplace
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label={t("Close (Escape)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
|
||||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
{replaceButton.isReplaceShow && editable && (
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
placeholder={t("Replace")}
|
|
||||||
leftSection={<IconReplace size={16} />}
|
|
||||||
rightSection={<div></div>}
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={180}
|
|
||||||
autoFocus
|
|
||||||
onChange={replaceInputEvent}
|
|
||||||
value={replaceText}
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", replace],
|
|
||||||
["ctrl+alt+Enter", replaceAll],
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Replace (Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replace}
|
|
||||||
>
|
|
||||||
{t("Replace")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replaceAll}
|
|
||||||
>
|
|
||||||
{t("Replace all")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SearchAndReplaceDialog;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
.findDialog{
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.findDialog div[data-position="right"].mantine-Input-section {
|
|
||||||
justify-content: right;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
ColorSwatch,
|
|
||||||
Popover,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export interface TableColorItem {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableBackgroundColorProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABLE_COLORS: TableColorItem[] = [
|
|
||||||
{ name: "Default", color: "" },
|
|
||||||
{ name: "Blue", color: "#b4d5ff" },
|
|
||||||
{ name: "Green", color: "#acf5d2" },
|
|
||||||
{ name: "Yellow", color: "#fef1b4" },
|
|
||||||
{ name: "Red", color: "#ffbead" },
|
|
||||||
{ name: "Pink", color: "#ffc7fe" },
|
|
||||||
{ name: "Gray", color: "#eaecef" },
|
|
||||||
{ name: "Purple", color: "#c1b7f2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|
||||||
editor,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const setTableCellBackground = (color: string, colorName: string) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.updateAttributes("tableCell", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.updateAttributes("tableHeader", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
setOpened(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get current cell's background color
|
|
||||||
const getCurrentColor = () => {
|
|
||||||
if (editor.isActive("tableCell")) {
|
|
||||||
const attrs = editor.getAttributes("tableCell");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
if (editor.isActive("tableHeader")) {
|
|
||||||
const attrs = editor.getAttributes("tableHeader");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentColor = getCurrentColor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
width={200}
|
|
||||||
position="bottom"
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: "pop" }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Background color")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<IconPalette size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Background color")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TABLE_COLORS.map((item, index) => (
|
|
||||||
<UnstyledButton
|
|
||||||
key={index}
|
|
||||||
onClick={() => setTableCellBackground(item.color, item.name)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
}}
|
|
||||||
title={t(item.name)}
|
|
||||||
>
|
|
||||||
<ColorSwatch
|
|
||||||
color={item.color || "#ffffff"}
|
|
||||||
size={24}
|
|
||||||
style={{
|
|
||||||
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentColor === item.color && (
|
|
||||||
<IconCheck
|
|
||||||
size={18}
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
item.color === "" || item.color.startsWith("#F")
|
|
||||||
? "#000000"
|
|
||||||
: "#ffffff",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ColorSwatch>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -12,11 +12,8 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowRemove,
|
IconRowRemove,
|
||||||
IconSquareToggle,
|
IconSquareToggle,
|
||||||
IconTableRow,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@ -48,10 +45,6 @@ export const TableCellMenu = React.memo(
|
|||||||
editor.chain().focus().deleteRow().run();
|
editor.chain().focus().deleteRow().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderCell = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderCell().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@ -67,9 +60,6 @@ export const TableCellMenu = React.memo(
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
|
||||||
<TableTextAlignment editor={editor} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@ -113,17 +103,6 @@ export const TableCellMenu = React.memo(
|
|||||||
<IconRowRemove size={18} />
|
<IconRowRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderCell}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header cell")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import {
|
|
||||||
IconAlignCenter,
|
|
||||||
IconAlignLeft,
|
|
||||||
IconAlignRight,
|
|
||||||
IconCheck,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
rem,
|
|
||||||
ScrollArea,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface TableTextAlignmentProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlignmentItem {
|
|
||||||
name: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
command: () => void;
|
|
||||||
isActive: () => boolean;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const items: AlignmentItem[] = [
|
|
||||||
{
|
|
||||||
name: "Align left",
|
|
||||||
value: "left",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
|
||||||
icon: IconAlignLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align center",
|
|
||||||
value: "center",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
|
||||||
icon: IconAlignCenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align right",
|
|
||||||
value: "right",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
|
||||||
icon: IconAlignRight,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeItem = items.find((item) => item.isActive()) || items[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: 'pop' }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Text alignment")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<activeItem.icon size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<ScrollArea.Autosize type="scroll" mah={300}>
|
|
||||||
<Button.Group orientation="vertical">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<item.icon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
item.isActive() && <IconCheck size={16} />
|
|
||||||
}
|
|
||||||
justify="left"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setOpened(false);
|
|
||||||
}}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
{t(item.name)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Button.Group>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -10,6 +10,8 @@ import { Highlight } from "@tiptap/extension-highlight";
|
|||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Table from "@tiptap/extension-table";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
@ -23,8 +25,6 @@ import {
|
|||||||
MathInline,
|
MathInline,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
|
||||||
CustomTable,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@ -36,7 +36,6 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@ -74,7 +73,6 @@ 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);
|
||||||
@ -160,7 +158,7 @@ export const mainExtensions = [
|
|||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
CustomTable.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
@ -215,25 +213,7 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({
|
CharacterCount
|
||||||
wordCounter: (text) => countWords(text),
|
|
||||||
}),
|
|
||||||
SearchAndReplace.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Mod-f': () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
'Escape': () => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).configure(),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
@ -249,4 +229,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
|||||||
color: randomElement(userColors),
|
color: randomElement(userColors),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@ -42,11 +42,7 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||||
pageId={pageId}
|
|
||||||
editable={editable}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
@ -39,7 +45,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
|||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
@ -47,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
@ -67,15 +71,11 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||||
if (!ydocRef.current) {
|
|
||||||
ydocRef.current = new Y.Doc();
|
|
||||||
}
|
|
||||||
const ydoc = ydocRef.current;
|
|
||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
@ -85,126 +85,67 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
// Providers only created once per pageId
|
const localProvider = useMemo(() => {
|
||||||
const providersRef = useRef<{
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
const localProvider = providersRef.current?.local;
|
provider.on("synced", () => {
|
||||||
const remoteProvider = providersRef.current?.remote;
|
setLocalSynced(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Track when collaborative provider is ready and synced
|
return provider;
|
||||||
const [collabReady, setCollabReady] = useState(false);
|
}, [pageId, ydoc]);
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
|
||||||
isLocalSynced &&
|
|
||||||
isRemoteSynced
|
|
||||||
) {
|
|
||||||
setCollabReady(true);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const remoteProvider = useMemo(() => {
|
||||||
if (!providersRef.current) {
|
const provider = new HocuspocusProvider({
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
name: documentName,
|
||||||
local.on("synced", () => setLocalSynced(true));
|
url: collaborationURL,
|
||||||
const remote = new HocuspocusProvider({
|
document: ydoc,
|
||||||
name: documentName,
|
token: collabQuery?.token,
|
||||||
url: collaborationURL,
|
connect: false,
|
||||||
document: ydoc,
|
preserveConnection: false,
|
||||||
token: collabQuery?.token,
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
connect: true,
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
preserveConnection: false,
|
const now = Date.now().valueOf() / 1000;
|
||||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
const isTokenExpired = now >= payload.exp;
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
if (isTokenExpired) {
|
||||||
const now = Date.now().valueOf() / 1000;
|
refetchCollabToken();
|
||||||
const isTokenExpired = now >= payload.exp;
|
}
|
||||||
if (isTokenExpired) {
|
},
|
||||||
refetchCollabToken().then((result) => {
|
onStatus: (status) => {
|
||||||
if (result.data?.token) {
|
if (status.status === "connected") {
|
||||||
remote.disconnect();
|
setYjsConnectionStatus(status.status);
|
||||||
setTimeout(() => {
|
}
|
||||||
remote.configuration.token = result.data.token;
|
},
|
||||||
remote.connect();
|
});
|
||||||
}, 100);
|
|
||||||
}
|
provider.on("synced", () => {
|
||||||
});
|
setRemoteSynced(true);
|
||||||
}
|
});
|
||||||
},
|
|
||||||
onStatus: (status) => {
|
provider.on("disconnect", () => {
|
||||||
if (status.status === "connected") {
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
setYjsConnectionStatus(status.status);
|
});
|
||||||
}
|
|
||||||
},
|
return provider;
|
||||||
});
|
}, [ydoc, pageId, collabQuery?.token]);
|
||||||
remote.on("synced", () => setRemoteSynced(true));
|
|
||||||
remote.on("disconnect", () => {
|
useLayoutEffect(() => {
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
remoteProvider.connect();
|
||||||
});
|
|
||||||
providersRef.current = { local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.remote.destroy();
|
setRemoteSynced(false);
|
||||||
providersRef.current?.local.destroy();
|
setLocalSynced(false);
|
||||||
providersRef.current = null;
|
remoteProvider.destroy();
|
||||||
|
localProvider.destroy();
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [remoteProvider, localProvider]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle token updates by reconnecting with new token
|
|
||||||
if (providersRef.current?.remote && collabQuery?.token) {
|
|
||||||
const currentToken = providersRef.current.remote.configuration.token;
|
|
||||||
if (currentToken !== collabQuery.token) {
|
|
||||||
// Token has changed, need to reconnect with new token
|
|
||||||
providersRef.current.remote.disconnect();
|
|
||||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
|
||||||
providersRef.current.remote.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
remoteProvider.disconnect();
|
|
||||||
setIsCollabReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
remoteProvider.connect();
|
|
||||||
setTimeout(() => setIsCollabReady(true), 500);
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [remoteProvider, currentUser?.user]);
|
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@ -217,10 +158,6 @@ export default function PageEditor({
|
|||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
|
||||||
event.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
@ -262,7 +199,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider]
|
[pageId, editable, remoteProvider?.status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@ -278,12 +215,7 @@ export default function PageEditor({
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId, resolved } = event.detail;
|
const { commentId } = event.detail;
|
||||||
|
|
||||||
if (resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
@ -300,7 +232,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent
|
handleActiveCommentEvent,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -320,6 +252,29 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [remoteProvider?.status]);
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
remoteProvider.disconnect();
|
||||||
|
setIsCollabReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
remoteProvider.connect();
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCollabReady(true);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, remoteProvider]);
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -335,54 +290,11 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(collabReadyTimeout);
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
return isCollabReady ? (
|
||||||
// Only honor user default page edit mode preference and permissions
|
<div>
|
||||||
if (editor) {
|
|
||||||
if (userPageEditMode && editable) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
editor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, editor, editable]);
|
|
||||||
|
|
||||||
const hasConnectedOnceRef = useRef(false);
|
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!hasConnectedOnceRef.current &&
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
hasConnectedOnceRef.current = true;
|
|
||||||
setShowStatic(false);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status]);
|
|
||||||
|
|
||||||
if (showStatic) {
|
|
||||||
return (
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={mainExtensions}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
{editor && (
|
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editor && editor.isEditable && (
|
{editor && editor.isEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
@ -396,12 +308,21 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
style={{ paddingBottom: "20vh" }}
|
style={{ paddingBottom: "20vh" }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<EditorProvider
|
||||||
|
editable={false}
|
||||||
|
immediatelyRender={true}
|
||||||
|
extensions={mainExtensions}
|
||||||
|
content={content}
|
||||||
|
></EditorProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,11 +142,6 @@
|
|||||||
.comment-mark {
|
.comment-mark {
|
||||||
background: rgba(255, 215, 0, 0.14);
|
background: rgba(255, 215, 0, 0.14);
|
||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
|
|
||||||
&.resolved {
|
|
||||||
background: none;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
@ -192,7 +187,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,12 +71,4 @@
|
|||||||
[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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
.search-result{
|
|
||||||
background: #ffff65;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-current{
|
|
||||||
background: #ffc266 !important;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
@ -9,6 +9,5 @@
|
|||||||
@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";
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
/* 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,7 +4,6 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-width: 700px !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,8 +38,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;
|
||||||
@ -67,54 +66,8 @@
|
|||||||
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,11 +10,8 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
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";
|
||||||
@ -24,8 +21,6 @@ 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;
|
||||||
@ -43,16 +38,12 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||||
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: [
|
||||||
@ -112,12 +103,7 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: {
|
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||||
title: page.title,
|
|
||||||
slugId: page.slugId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
icon: page.icon,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
if (page.title !== titleEditor.getText()) return;
|
||||||
@ -150,30 +136,9 @@ export function TitleEditor({
|
|||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTitleKeyDown(event) {
|
||||||
// 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;
|
||||||
|
|
||||||
@ -187,16 +152,5 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||||
<EditorContent
|
|
||||||
editor={titleEditor}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
// First handle the search hotkey
|
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
||||||
|
|
||||||
// Then handle other key events
|
|
||||||
handleTitleKeyDown(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
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<IPagination<IPageHistory>> {
|
): Promise<IPageHistory[]> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,30 +1,24 @@
|
|||||||
.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 className={classes.breadcrumbDiv}>
|
<div style={{ overflow: "hidden" }}>
|
||||||
{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 { duplicatePage } from "@/features/page/services/page-service.ts";
|
import { copyPageToSpace } 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 duplicatePage({
|
const copiedPage = await copyPageToSpace({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@ -17,12 +16,7 @@ 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 {
|
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||||
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";
|
||||||
@ -38,9 +32,7 @@ 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";
|
||||||
@ -53,26 +45,6 @@ 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" && (
|
||||||
@ -87,8 +59,6 @@ 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>
|
||||||
@ -231,7 +201,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={handleDeletePage}
|
onClick={handleDeletePage}
|
||||||
>
|
>
|
||||||
{t("Move to trash")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,27 +1,15 @@
|
|||||||
.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 (max-width: $mantine-breakpoint-sm) {
|
@media print {
|
||||||
padding-left: var(--mantine-spacing-xs);
|
display: none;
|
||||||
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" className={classes.group}>
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||||
<PageHeaderMenu readOnly={readOnly} />
|
<PageHeaderMenu readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -4,37 +4,26 @@ 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 = ({
|
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
||||||
onConfirm,
|
|
||||||
isPermanent = false,
|
|
||||||
}: UseDeleteModalProps) => {
|
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: isPermanent
|
title: t("Are you sure you want to delete this page?"),
|
||||||
? t("Are you sure you want to delete this page?")
|
|
||||||
: t("Move this page to trash?"),
|
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{isPermanent
|
{t(
|
||||||
? t(
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||||
"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: {
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import {
|
|||||||
UseInfiniteQueryResult,
|
UseInfiniteQueryResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
keepPreviousData,
|
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createPage,
|
createPage,
|
||||||
@ -18,8 +18,6 @@ import {
|
|||||||
getPageBreadcrumbs,
|
getPageBreadcrumbs,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
|
||||||
restorePage,
|
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@ -28,17 +26,12 @@ import {
|
|||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
import { SimpleTree } from "react-arborist";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
@ -77,7 +70,10 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function updatePageData(data: IPage) {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||||
|
"pages",
|
||||||
|
data.slugId,
|
||||||
|
]);
|
||||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||||
|
|
||||||
if (pageBySlug) {
|
if (pageBySlug) {
|
||||||
@ -91,13 +87,7 @@ export function updatePageData(data: IPage) {
|
|||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||||
data.spaceId,
|
|
||||||
data.parentPageId,
|
|
||||||
data.id,
|
|
||||||
data.title,
|
|
||||||
data.icon,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateTitlePageMutation() {
|
export function useUpdateTitlePageMutation() {
|
||||||
@ -112,29 +102,7 @@ export function useUpdatePageMutation() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
updatePage(data);
|
updatePage(data);
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||||
data.spaceId,
|
|
||||||
data.parentPageId,
|
|
||||||
data.id,
|
|
||||||
data.title,
|
|
||||||
data.icon,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemovePageMutation() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: "Page moved to trash" });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["trash-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -142,16 +110,10 @@ export function useRemovePageMutation() {
|
|||||||
export function useDeletePageMutation() {
|
export function useDeletePageMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, true),
|
mutationFn: (pageId: string) => deletePage(pageId),
|
||||||
onSuccess: (data, pageId) => {
|
onSuccess: (data, pageId) => {
|
||||||
notifications.show({ message: t("Page deleted successfully") });
|
notifications.show({ message: t("Page deleted successfully") });
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
|
|
||||||
// Invalidate to refresh trash lists
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["trash-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||||
@ -168,87 +130,7 @@ export function useMovePageMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRestorePageMutation() {
|
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (pageId: string) => restorePage(pageId),
|
|
||||||
onSuccess: async (restoredPage) => {
|
|
||||||
notifications.show({ message: "Page restored successfully" });
|
|
||||||
|
|
||||||
// Add the restored page back to the tree
|
|
||||||
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
|
|
||||||
|
|
||||||
// Check if the page already exists in the tree (it shouldn't)
|
|
||||||
if (!treeApi.find(restoredPage.id)) {
|
|
||||||
// Create the tree node data with hasChildren from backend
|
|
||||||
const nodeData: SpaceTreeNode = {
|
|
||||||
id: restoredPage.id,
|
|
||||||
slugId: restoredPage.slugId,
|
|
||||||
name: restoredPage.title || "Untitled",
|
|
||||||
icon: restoredPage.icon,
|
|
||||||
position: restoredPage.position,
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
parentPageId: restoredPage.parentPageId,
|
|
||||||
hasChildren: restoredPage.hasChildren || false,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the parent and index
|
|
||||||
const parentId = restoredPage.parentPageId || null;
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const parentNode = treeApi.find(parentId);
|
|
||||||
if (parentNode) {
|
|
||||||
index = parentNode.children?.length || 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Root level page
|
|
||||||
index = treeApi.data.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the node to the tree
|
|
||||||
treeApi.create({
|
|
||||||
parentId,
|
|
||||||
index,
|
|
||||||
data: nodeData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the tree data
|
|
||||||
setTreeData(treeApi.data);
|
|
||||||
|
|
||||||
// Emit websocket event to sync with other users
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index,
|
|
||||||
data: nodeData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
|
|
||||||
|
|
||||||
// Also invalidate deleted pages query to refresh the trash list
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["trash-list", restoredPage.spaceId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetSidebarPagesQuery(
|
|
||||||
data: SidebarPagesParams | null,
|
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
@ -306,20 +188,6 @@ export function useRecentChangesQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeletedPagesQuery(
|
|
||||||
spaceId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<IPage>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["trash-list", spaceId, params],
|
|
||||||
queryFn: () => getDeletedPages(spaceId, params),
|
|
||||||
enabled: !!spaceId,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
refetchOnMount: true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||||
const newPage: Partial<IPage> = {
|
const newPage: Partial<IPage> = {
|
||||||
creatorId: data.creatorId,
|
creatorId: data.creatorId,
|
||||||
@ -334,40 +202,34 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (data.parentPageId === null) {
|
if (data.parentPageId===null) {
|
||||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
queryKey = ['root-sidebar-pages', data.spaceId];
|
||||||
} else {
|
}else{
|
||||||
queryKey = [
|
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
||||||
"sidebar-pages",
|
|
||||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
||||||
queryKey,
|
if (!old) return old;
|
||||||
(old) => {
|
return {
|
||||||
if (!old) return old;
|
...old,
|
||||||
return {
|
pages: old.pages.map((page,index) => {
|
||||||
...old,
|
if (index === old.pages.length - 1) {
|
||||||
pages: old.pages.map((page, index) => {
|
return {
|
||||||
if (index === old.pages.length - 1) {
|
...page,
|
||||||
return {
|
items: [...page.items, newPage],
|
||||||
...page,
|
};
|
||||||
items: [...page.items, newPage],
|
}
|
||||||
};
|
return page;
|
||||||
}
|
}),
|
||||||
return page;
|
};
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
//update sidebar haschildren
|
//update sidebar haschildren
|
||||||
if (data.parentPageId !== null) {
|
if (data.parentPageId!==null){
|
||||||
//update sub sidebar pages haschildern
|
//update sub sidebar pages haschildern
|
||||||
const subSideBarMatches = queryClient.getQueriesData({
|
const subSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["sidebar-pages"],
|
queryKey: ['sidebar-pages'],
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -379,10 +241,8 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
sidebarPage.id === data.parentPageId
|
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||||
? { ...sidebarPage, hasChildren: true }
|
)
|
||||||
: sidebarPage,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -390,7 +250,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update root sidebar pages haschildern
|
//update root sidebar pages haschildern
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
const rootSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ['root-sidebar-pages', data.spaceId],
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -402,10 +262,8 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
sidebarPage.id === data.parentPageId
|
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||||
? { ...sidebarPage, hasChildren: true }
|
)
|
||||||
: sidebarPage,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -418,38 +276,27 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnUpdatePage(
|
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
||||||
spaceId: string,
|
|
||||||
parentPageId: string,
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
icon: string,
|
|
||||||
) {
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if(parentPageId===null){
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = ['root-sidebar-pages', spaceId];
|
||||||
} else {
|
}else{
|
||||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
||||||
}
|
}
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
||||||
queryKey,
|
if (!old) return old;
|
||||||
(old) => {
|
return {
|
||||||
if (!old) return old;
|
...old,
|
||||||
return {
|
pages: old.pages.map((page) => ({
|
||||||
...old,
|
...page,
|
||||||
pages: old.pages.map((page) => ({
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
...page,
|
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
)
|
||||||
sidebarPage.id === id
|
})),
|
||||||
? { ...sidebarPage, title: title, icon: icon }
|
};
|
||||||
: sidebarPage,
|
});
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: ["recent-changes", spaceId],
|
||||||
@ -464,7 +311,7 @@ export function invalidateOnMovePage() {
|
|||||||
});
|
});
|
||||||
//invalidate all sub sidebar pages
|
//invalidate all sub sidebar pages
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["sidebar-pages"],
|
queryKey: ['sidebar-pages'],
|
||||||
});
|
});
|
||||||
// ---
|
// ---
|
||||||
}
|
}
|
||||||
@ -473,8 +320,7 @@ export function invalidateOnDeletePage(pageId: string) {
|
|||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key, d]) => {
|
allSideBarMatches.forEach(([key, d]) => {
|
||||||
@ -484,16 +330,14 @@ export function invalidateOnDeletePage(pageId: string) {
|
|||||||
...old,
|
...old,
|
||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.filter(
|
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
||||||
(sidebarPage: IPage) => sidebarPage.id !== pageId,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes"],
|
queryKey: ["recent-changes"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -8,7 +8,6 @@ import {
|
|||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from '@/features/page/types/page.types';
|
||||||
import { QueryParams } from "@/lib/types";
|
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
import { InfiniteData } from "@tanstack/react-query";
|
||||||
@ -31,21 +30,8 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
|
export async function deletePage(pageId: string): Promise<void> {
|
||||||
await api.post("/pages/delete", { pageId, permanentlyDelete });
|
await api.post("/pages/delete", { pageId });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeletedPages(
|
|
||||||
spaceId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): Promise<IPagination<IPage>> {
|
|
||||||
const req = await api.post("/pages/trash", { spaceId, ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restorePage(pageId: string): Promise<IPage> {
|
|
||||||
const response = await api.post<IPage>("/pages/restore", { pageId });
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function movePage(data: IMovePage): Promise<void> {
|
export async function movePage(data: IMovePage): Promise<void> {
|
||||||
@ -56,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
|||||||
await api.post<void>("/pages/move-to-space", data);
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/duplicate", data);
|
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
import { Modal, Text, ScrollArea } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
pageTitle: string;
|
|
||||||
pageContent: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrashPageContentModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
pageTitle,
|
|
||||||
pageContent,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const title = pageTitle || t("Untitled");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
|
||||||
<Modal.Overlay />
|
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
|
||||||
<Modal.Header>
|
|
||||||
<Modal.Title>
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
{t("Preview")}
|
|
||||||
</Text>
|
|
||||||
</Modal.Title>
|
|
||||||
<Modal.CloseButton />
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body p={0}>
|
|
||||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
|
||||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
|
||||||
</ScrollArea>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +1,4 @@
|
|||||||
import {
|
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||||
NodeApi,
|
|
||||||
NodeRendererProps,
|
|
||||||
Tree,
|
|
||||||
TreeApi,
|
|
||||||
SimpleTree,
|
|
||||||
} from "react-arborist";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
@ -72,7 +66,6 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||||
import { duplicatePage } from "../../services/page-service.ts";
|
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -97,14 +90,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const [isRootReady, setIsRootReady] = useState(false);
|
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef((element) => {
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
rootElement.current = element;
|
|
||||||
if (element && !isRootReady) {
|
|
||||||
setIsRootReady(true);
|
|
||||||
}
|
|
||||||
}, sizeRef);
|
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
@ -212,17 +199,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [currentPage?.id]);
|
}, [currentPage?.id]);
|
||||||
|
|
||||||
// Clean up tree API on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (treeApiRef.current) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTreeApi(null);
|
setTreeApi(treeApiRef.current);
|
||||||
};
|
}
|
||||||
}, [setTreeApi]);
|
}, [treeApiRef.current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{isRootReady && rootElement.current && (
|
{rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
@ -231,13 +217,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={rootElement.current.clientHeight}
|
height={rootElement.current.clientHeight}
|
||||||
ref={(ref) => {
|
ref={treeApiRef}
|
||||||
treeApiRef.current = ref;
|
|
||||||
if (ref) {
|
|
||||||
//@ts-ignore
|
|
||||||
setTreeApi(ref);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
@ -403,7 +383,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
<NodeMenu node={node} treeApi={tree} />
|
||||||
|
|
||||||
{!tree.props.disableEdit && (
|
{!tree.props.disableEdit && (
|
||||||
<CreateNode
|
<CreateNode
|
||||||
@ -456,16 +436,13 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
|||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
node: NodeApi<SpaceTreeNode>;
|
node: NodeApi<SpaceTreeNode>;
|
||||||
treeApi: TreeApi<SpaceTreeNode>;
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
spaceId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [
|
const [
|
||||||
@ -484,68 +461,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicatePage = async () => {
|
|
||||||
try {
|
|
||||||
const duplicatedPage = await duplicatePage({
|
|
||||||
pageId: node.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the index of the current node
|
|
||||||
const parentId =
|
|
||||||
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
|
||||||
? null
|
|
||||||
: node.parent?.id;
|
|
||||||
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
|
||||||
const currentIndex =
|
|
||||||
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
|
||||||
const newIndex = currentIndex + 1;
|
|
||||||
|
|
||||||
// Add the duplicated page to the tree
|
|
||||||
const treeNodeData: SpaceTreeNode = {
|
|
||||||
id: duplicatedPage.id,
|
|
||||||
slugId: duplicatedPage.slugId,
|
|
||||||
name: duplicatedPage.title,
|
|
||||||
position: duplicatedPage.position,
|
|
||||||
spaceId: duplicatedPage.spaceId,
|
|
||||||
parentPageId: duplicatedPage.parentPageId,
|
|
||||||
icon: duplicatedPage.icon,
|
|
||||||
hasChildren: duplicatedPage.hasChildren,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update local tree
|
|
||||||
const simpleTree = new SimpleTree(data);
|
|
||||||
simpleTree.create({
|
|
||||||
parentId,
|
|
||||||
index: newIndex,
|
|
||||||
data: treeNodeData,
|
|
||||||
});
|
|
||||||
setData(simpleTree.data);
|
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: spaceId,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index: newIndex,
|
|
||||||
data: treeNodeData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: t("Page duplicated successfully"),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err.response?.data.message || "An error occurred",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
@ -590,17 +505,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconCopy size={16} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDuplicatePage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Duplicate")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconArrowRight size={16} />}
|
leftSection={<IconArrowRight size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -620,7 +524,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
openCopyPageModal();
|
openCopyPageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Copy to space")}
|
{t("Copy")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@ -633,7 +537,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Move to trash")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
useRemovePageMutation,
|
useDeletePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const removePageMutation = useRemovePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
|
|
||||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||||
try {
|
try {
|
||||||
await removePageMutation.mutateAsync(args.ids[0]);
|
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||||
|
|
||||||
const node = tree.find(args.ids[0]);
|
const node = tree.find(args.ids[0]);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
|
|||||||
@ -20,7 +20,6 @@ export interface IPage {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
creator: ICreator;
|
creator: ICreator;
|
||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,12 +34,6 @@ interface ILastUpdatedBy {
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IDeletedBy {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMovePage {
|
export interface IMovePage {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
@ -56,7 +49,7 @@ export interface IMovePageToSpace {
|
|||||||
|
|
||||||
export interface ICopyPageToSpace {
|
export interface ICopyPageToSpace {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
spaceId?: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
@ -72,7 +65,6 @@ export interface IPageInput {
|
|||||||
icon: string;
|
icon: string;
|
||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
position: string;
|
position: string;
|
||||||
isLocked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExportPageParams {
|
export interface IExportPageParams {
|
||||||
|
|||||||
@ -26,9 +26,6 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
{option["type"] === "group" && <IconGroupCircle />}
|
{option["type"] === "group" && <IconGroupCircle />}
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||||
{option["type"] === "user" && option["email"] && (
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@ -50,7 +47,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
const userItems = suggestion?.users.map((user: IUser) => ({
|
const userItems = suggestion?.users.map((user: IUser) => ({
|
||||||
value: `user-${user.id}`,
|
value: `user-${user.id}`,
|
||||||
label: user.name,
|
label: user.name,
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
type: "user",
|
type: "user",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@ -207,7 +206,6 @@ interface SpaceMenuProps {
|
|||||||
}
|
}
|
||||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
@ -255,14 +253,6 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Space settings")}
|
{t("Space settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
to={`/s/${spaceSlug}/trash`}
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
>
|
|
||||||
{t("Trash")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
prefetchSpace,
|
prefetchSpace,
|
||||||
@ -9,11 +9,10 @@ import { Link } from "react-router-dom";
|
|||||||
import classes from "./space-grid.module.css";
|
import classes from "./space-grid.module.css";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 9 });
|
const { data, isLoading } = useGetSpacesQuery({ page: 1 });
|
||||||
|
|
||||||
const cards = data?.items.map((space, index) => (
|
const cards = data?.items.map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
@ -47,25 +46,11 @@ export default function SpaceGrid() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Text fz="sm" fw={500} mb={"md"}>
|
||||||
<Text fz="sm" fw={500}>
|
{t("Spaces you belong to")}
|
||||||
{t("Spaces you belong to")}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="lg">
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to="/spaces"
|
|
||||||
variant="subtle"
|
|
||||||
rightSection={<IconArrowRight size={16} />}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("View all spaces")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
.spaceLink {
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
import {
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
|
||||||
Box,
|
|
||||||
Space,
|
|
||||||
Menu,
|
|
||||||
Avatar,
|
|
||||||
Anchor,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { formatMemberCount } from "@/lib";
|
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
|
||||||
import { prefetchSpace } from "@/features/space/queries/space-query";
|
|
||||||
import { SearchInput } from "@/components/common/search-input";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|
||||||
import classes from "./all-spaces-list.module.css";
|
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
|
||||||
spaces: any[];
|
|
||||||
onSearch: (query: string) => void;
|
|
||||||
page: number;
|
|
||||||
hasPrevPage?: boolean;
|
|
||||||
hasNextPage?: boolean;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AllSpacesList({
|
|
||||||
spaces,
|
|
||||||
onSearch,
|
|
||||||
page,
|
|
||||||
hasPrevPage,
|
|
||||||
hasNextPage,
|
|
||||||
onPageChange,
|
|
||||||
}: AllSpacesListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
|
||||||
useDisclosure(false);
|
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleOpenSettings = (spaceId: string) => {
|
|
||||||
setSelectedSpaceId(spaceId);
|
|
||||||
openSettings();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<SearchInput onSearch={onSearch} />
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Space")}</Table.Th>
|
|
||||||
<Table.Th>{t("Members")}</Table.Th>
|
|
||||||
<Table.Th w={100}></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{spaces.length > 0 ? (
|
|
||||||
spaces.map((space) => (
|
|
||||||
<Table.Tr key={space.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={getSpaceUrl(space.slug)}
|
|
||||||
>
|
|
||||||
<Group
|
|
||||||
gap="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
className={classes.spaceLink}
|
|
||||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
color="initials"
|
|
||||||
variant="filled"
|
|
||||||
name={space.name}
|
|
||||||
size={40}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{space.name}
|
|
||||||
</Text>
|
|
||||||
{space.description && (
|
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
|
||||||
{space.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatMemberCount(space.memberCount, t)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs" justify="flex-end">
|
|
||||||
<Menu position="bottom-end">
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray">
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconSettings size={16} />}
|
|
||||||
onClick={() => handleOpenSettings(space.id)}
|
|
||||||
>
|
|
||||||
{t("Space settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={3} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
|
|
||||||
{spaces.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={hasPrevPage}
|
|
||||||
hasNextPage={hasNextPage}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedSpaceId && (
|
|
||||||
<SpaceSettingsModal
|
|
||||||
spaceId={selectedSpaceId}
|
|
||||||
opened={settingsOpened}
|
|
||||||
onClose={closeSettings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default as AllSpacesList } from "./all-spaces-list";
|
|
||||||
@ -42,15 +42,14 @@ function LanguageSwitcher() {
|
|||||||
label={t("Select language")}
|
label={t("Select language")}
|
||||||
data={[
|
data={[
|
||||||
{ value: "en-US", label: "English (US)" },
|
{ value: "en-US", label: "English (US)" },
|
||||||
{ value: "es-ES", label: "Español (Spanish)" },
|
|
||||||
{ value: "de-DE", label: "Deutsch (German)" },
|
{ value: "de-DE", label: "Deutsch (German)" },
|
||||||
{ value: "fr-FR", label: "Français (French)" },
|
|
||||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||||
|
{ value: "fr-FR", label: "Français (French)" },
|
||||||
|
{ value: "es-ES", label: "Español (Spanish)" },
|
||||||
{ value: "pt-BR", label: "Português (Brasil)" },
|
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||||
{ value: "it-IT", label: "Italiano (Italian)" },
|
{ value: "it-IT", label: "Italiano (Italian)" },
|
||||||
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
||||||
{ value: "ko-KR", label: "한국어 (Korean)" },
|
{ value: "ko-KR", label: "한국어 (Korean)" },
|
||||||
{ value: "uk-UA", label: "Українська (Ukrainian)" },
|
|
||||||
{ value: "ru-RU", label: "Русский (Russian)" },
|
{ value: "ru-RU", label: "Русский (Russian)" },
|
||||||
{ value: "zh-CN", label: "中文 (简体)" },
|
{ value: "zh-CN", label: "中文 (简体)" },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { isCloud } from "@/lib/config";
|
|
||||||
import { useLicense } from "@/ee/hooks/use-license";
|
|
||||||
import { MfaSettings } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function AccountMfaSection() {
|
|
||||||
const { hasLicenseKey } = useLicense();
|
|
||||||
const showMfa = isCloud() || hasLicenseKey;
|
|
||||||
|
|
||||||
if (!showMfa) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MfaSettings />;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user