mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-10 04:22:00 +10:00
Support I18n (#243)
* feat: support i18n * feat: wip support i18n * feat: complete space translation * feat: complete page translation * feat: update space translation * feat: update workspace translation * feat: update group translation * feat: update workspace translation * feat: update page translation * feat: update user translation * chore: update pnpm-lock * feat: add query translation * refactor: merge to single file * chore: remove necessary code * feat: save language to BE * fix: only load current language * feat: save language to locale column * fix: cleanups * add language menu to preferences page * new translations * translate editor * Translate editor placeholders * translate space selection component --------- Co-authored-by: Philip Okugbe <phil@docmost.com> Co-authored-by: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@ -29,6 +29,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"i18next": "^23.14.0",
|
||||||
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.10.3",
|
"jotai": "^2.10.3",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@ -43,6 +45,7 @@
|
|||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
|||||||
335
apps/client/public/locales/en-US/translation.json
Normal file
335
apps/client/public/locales/en-US/translation.json
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"Account": "Account",
|
||||||
|
"Active": "Active",
|
||||||
|
"Add": "Add ",
|
||||||
|
"Add group members": "Add group members",
|
||||||
|
"Add groups": "Add groups",
|
||||||
|
"Add members": "Add members",
|
||||||
|
"Add to groups": "Add to groups",
|
||||||
|
"Add space members": "Add space members",
|
||||||
|
"Admin": "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 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 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 group? The user will lose access to resources this group has access to.",
|
||||||
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||||
|
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||||
|
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||||
|
"Can edit": "Can edit",
|
||||||
|
"Can manage workspace": "Can manage workspace",
|
||||||
|
"Can manage workspace but cannot delete it": "Can manage workspace but cannot delete it",
|
||||||
|
"Can view": "Can view",
|
||||||
|
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Change email": "Change email",
|
||||||
|
"Change password": "Change password",
|
||||||
|
"Change photo": "Change photo",
|
||||||
|
"Choose a role": "Choose a role",
|
||||||
|
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
||||||
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
|
"Confirm": "Confirm",
|
||||||
|
"Copy link": "Copy link",
|
||||||
|
"Create": "Create",
|
||||||
|
"Create group": "Create group",
|
||||||
|
"Create page": "Create page",
|
||||||
|
"Create space": "Create space",
|
||||||
|
"Create workspace": "Create workspace",
|
||||||
|
"Current password": "Current password",
|
||||||
|
"Dark": "Dark",
|
||||||
|
"Date": "Date",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"Delete group": "Delete group",
|
||||||
|
"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.",
|
||||||
|
"Description": "Description",
|
||||||
|
"Details": "Details",
|
||||||
|
"e.g ACME": "e.g ACME",
|
||||||
|
"e.g ACME Inc": "e.g ACME Inc",
|
||||||
|
"e.g Developers": "e.g Developers",
|
||||||
|
"e.g Group for developers": "e.g Group for developers",
|
||||||
|
"e.g product": "e.g product",
|
||||||
|
"e.g Product Team": "e.g Product Team",
|
||||||
|
"e.g Sales": "e.g Sales",
|
||||||
|
"e.g Space for product team": "e.g Space for product team",
|
||||||
|
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"Edit group": "Edit group",
|
||||||
|
"Email": "Email",
|
||||||
|
"Enter a strong password": "Enter a strong password",
|
||||||
|
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 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",
|
||||||
|
"Enter your password": "Enter your password",
|
||||||
|
"Error fetching page data.": "Error fetching page data.",
|
||||||
|
"Error loading page history.": "Error loading page history.",
|
||||||
|
"Export": "Export",
|
||||||
|
"Failed to create page": "Failed to create page",
|
||||||
|
"Failed to delete page": "Failed to delete page",
|
||||||
|
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
||||||
|
"Failed to import pages": "Failed to import pages",
|
||||||
|
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||||
|
"Failed to update data": "Failed to update data",
|
||||||
|
"Full access": "Full access",
|
||||||
|
"Full page width": "Full page width",
|
||||||
|
"Full width": "Full width",
|
||||||
|
"General": "General",
|
||||||
|
"Group": "Group",
|
||||||
|
"Group description": "Group description",
|
||||||
|
"Group name": "Group name",
|
||||||
|
"Groups": "Groups",
|
||||||
|
"Has full access to space settings and pages.": "Has full access to space settings and pages.",
|
||||||
|
"Home": "Home",
|
||||||
|
"Import pages": "Import pages",
|
||||||
|
"Import pages & space settings": "Import pages & space settings",
|
||||||
|
"Importing pages": "Importing pages",
|
||||||
|
"invalid invitation link": "invalid invitation link",
|
||||||
|
"Invitation signup": "Invitation signup",
|
||||||
|
"Invite by email": "Invite by email",
|
||||||
|
"Invite members": "Invite members",
|
||||||
|
"Invite new members": "Invite new members",
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
|
||||||
|
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
|
||||||
|
"Join the workspace": "Join the workspace",
|
||||||
|
"Language": "Language",
|
||||||
|
"Light": "Light",
|
||||||
|
"Link copied": "Link copied",
|
||||||
|
"Login": "Login",
|
||||||
|
"Logout": "Logout",
|
||||||
|
"Manage Group": "Manage Group",
|
||||||
|
"Manage members": "Manage members",
|
||||||
|
"member": "member",
|
||||||
|
"Member": "Member",
|
||||||
|
"members": "members",
|
||||||
|
"Members": "Members",
|
||||||
|
"My preferences": "My preferences",
|
||||||
|
"My Profile": "My Profile",
|
||||||
|
"My profile": "My profile",
|
||||||
|
"Name": "Name",
|
||||||
|
"New email": "New email",
|
||||||
|
"New page": "New page",
|
||||||
|
"New password": "New password",
|
||||||
|
"No group found": "No group found",
|
||||||
|
"No page history saved yet.": "No page history saved yet.",
|
||||||
|
"No pages yet": "No pages yet",
|
||||||
|
"No results found...": "No results found...",
|
||||||
|
"No user found": "No user found",
|
||||||
|
"Overview": "Overview",
|
||||||
|
"Owner": "Owner",
|
||||||
|
"page": "page",
|
||||||
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
|
"Page history": "Page history",
|
||||||
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
|
"Pages": "Pages",
|
||||||
|
"pages": "pages",
|
||||||
|
"Password": "Password",
|
||||||
|
"Password changed successfully": "Password changed successfully",
|
||||||
|
"Pending": "Pending",
|
||||||
|
"Please confirm your action": "Please confirm your action",
|
||||||
|
"Preferences": "Preferences",
|
||||||
|
"Print PDF": "Print PDF",
|
||||||
|
"Profile": "Profile",
|
||||||
|
"Recently updated": "Recently updated",
|
||||||
|
"Remove": "Remove",
|
||||||
|
"Remove group member": "Remove group member",
|
||||||
|
"Remove space member": "Remove space member",
|
||||||
|
"Restore": "Restore",
|
||||||
|
"Role": "Role",
|
||||||
|
"Save": "Save",
|
||||||
|
"Search": "Search",
|
||||||
|
"Search for groups": "Search for groups",
|
||||||
|
"Search for users": "Search for users",
|
||||||
|
"Search for users and groups": "Search for users and groups",
|
||||||
|
"Search...": "Search...",
|
||||||
|
"Select language": "Select language",
|
||||||
|
"Select role": "Select role",
|
||||||
|
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
||||||
|
"Select theme": "Select theme",
|
||||||
|
"Send invitation": "Send invitation",
|
||||||
|
"Settings": "Settings",
|
||||||
|
"Setup workspace": "Setup workspace",
|
||||||
|
"Sign In": "Sign In",
|
||||||
|
"Sign Up": "Sign Up",
|
||||||
|
"Slug": "Slug",
|
||||||
|
"Space": "Space",
|
||||||
|
"Space description": "Space description",
|
||||||
|
"Space menu": "Space menu",
|
||||||
|
"Space name": "Space name",
|
||||||
|
"Space settings": "Space settings",
|
||||||
|
"Space slug": "Space slug",
|
||||||
|
"Spaces": "Spaces",
|
||||||
|
"Spaces you belong to": "Spaces you belong to",
|
||||||
|
"No space found": "No space found",
|
||||||
|
"Search for spaces": "Search for spaces",
|
||||||
|
"Start typing to search...": "Start typing to search...",
|
||||||
|
"Status": "Status",
|
||||||
|
"Successfully imported": "Successfully imported",
|
||||||
|
"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",
|
||||||
|
"Untitled": "Untitled",
|
||||||
|
"Updated successfully": "Updated successfully",
|
||||||
|
"User": "User",
|
||||||
|
"Workspace": "Workspace",
|
||||||
|
"Workspace Name": "Workspace Name",
|
||||||
|
"Workspace settings": "Workspace settings",
|
||||||
|
"You can change your password here.": "You can change your password here.",
|
||||||
|
"Your Email": "Your Email",
|
||||||
|
"Your import is complete.": "Your import is complete.",
|
||||||
|
"Your name": "Your name",
|
||||||
|
"Your Name": "Your Name",
|
||||||
|
"Your password": "Your password",
|
||||||
|
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
|
||||||
|
"Sidebar toggle": "Sidebar toggle",
|
||||||
|
"Comments": "Comments",
|
||||||
|
"404 page not found": "404 page not found",
|
||||||
|
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
|
||||||
|
"Take me back to homepage": "Take me back to homepage",
|
||||||
|
"Forgot password": "Forgot password",
|
||||||
|
"Forgot your password?": "Forgot your password?",
|
||||||
|
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
|
||||||
|
"Send reset link": "Send reset link",
|
||||||
|
"Password reset": "Password reset",
|
||||||
|
"Your new password": "Your new password",
|
||||||
|
"Set password": "Set password",
|
||||||
|
"Write a comment": "Write a comment",
|
||||||
|
"Reply...": "Reply...",
|
||||||
|
"Error loading comments.": "Error loading comments.",
|
||||||
|
"No comments yet.": "No comments yet.",
|
||||||
|
"Edit comment": "Edit comment",
|
||||||
|
"Delete comment": "Delete comment",
|
||||||
|
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
||||||
|
"Comment created successfully": "Comment created successfully",
|
||||||
|
"Error creating comment": "Error creating comment",
|
||||||
|
"Comment updated successfully": "Comment updated successfully",
|
||||||
|
"Failed to update comment": "Failed to update comment",
|
||||||
|
"Comment deleted successfully": "Comment deleted successfully",
|
||||||
|
"Failed to delete comment": "Failed to delete comment",
|
||||||
|
"Comment resolved successfully": "Comment resolved successfully",
|
||||||
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
|
"Revoke invitation": "Revoke invitation",
|
||||||
|
"Revoke": "Revoke",
|
||||||
|
"Don't": "Don't",
|
||||||
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.",
|
||||||
|
"Resend invitation": "Resend invitation",
|
||||||
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
|
"Invite link": "Invite link",
|
||||||
|
"Copy": "Copy",
|
||||||
|
"Copied": "Copied",
|
||||||
|
"Select a user": "Select a user",
|
||||||
|
"Select a group": "Select a group",
|
||||||
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
|
"Delete space": "Delete space",
|
||||||
|
"Delete this space with all its pages and data.": "Delete this space with all its pages and data.",
|
||||||
|
"Format": "Format",
|
||||||
|
"Include subpages": "Include subpages",
|
||||||
|
"Include attachments": "Include attachments",
|
||||||
|
"Select export format": "Select export format",
|
||||||
|
"Export failed:": "Export failed:",
|
||||||
|
"export error": "export error",
|
||||||
|
"Export page": "Export page",
|
||||||
|
"Export space": "Export space",
|
||||||
|
"Export {{type}}": "Export {{type}}",
|
||||||
|
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||||
|
"Align left": "Align left",
|
||||||
|
"Align right": "Align right",
|
||||||
|
"Align center": "Align center",
|
||||||
|
"Merge cells": "Merge cells",
|
||||||
|
"Split cell": "Split cell",
|
||||||
|
"Delete column": "Delete column",
|
||||||
|
"Delete row": "Delete row",
|
||||||
|
"Add left column": "Add left column",
|
||||||
|
"Add right column": "Add right column",
|
||||||
|
"Add row above": "Add row above",
|
||||||
|
"Add row below": "Add row below",
|
||||||
|
"Delete table": "Delete table",
|
||||||
|
"Info": "Info",
|
||||||
|
"Success": "Success",
|
||||||
|
"Warning": "Warning",
|
||||||
|
"Danger": "Danger",
|
||||||
|
"Mermaid diagram error:": "Mermaid diagram error:",
|
||||||
|
"Invalid Mermaid diagram": "Invalid Mermaid diagram",
|
||||||
|
"Double-click to edit Draw.io diagram": "Double-click to edit Draw.io diagram",
|
||||||
|
"Exit": "Exit",
|
||||||
|
"Save & Exit": "Save & Exit",
|
||||||
|
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
|
||||||
|
"Paste link": "Paste link",
|
||||||
|
"Edit link": "Edit link",
|
||||||
|
"Remove link": "Remove link",
|
||||||
|
"Add link": "Add link",
|
||||||
|
"Please enter a valid url": "Please enter a valid url",
|
||||||
|
"Empty equation": "Empty equation",
|
||||||
|
"Invalid equation": "Invalid equation",
|
||||||
|
"Color": "Color",
|
||||||
|
"Text color": "Text color",
|
||||||
|
"Default": "Default",
|
||||||
|
"Blue": "Blue",
|
||||||
|
"Green": "Green",
|
||||||
|
"Purple": "Purple",
|
||||||
|
"Red": "Red",
|
||||||
|
"Yellow": "Yellow",
|
||||||
|
"Orange": "Orange",
|
||||||
|
"Pink": "Pink",
|
||||||
|
"Gray": "Gray",
|
||||||
|
"Embed link": "Embed link",
|
||||||
|
"Invalid {{provider}} embed link": "",
|
||||||
|
"Embed {{provider}}": "Embed {{provider}}",
|
||||||
|
"Enter {{provider}} link to embed": "Enter {{provider}} link to embed",
|
||||||
|
"Bold": "Bold",
|
||||||
|
"Italic": "Italic",
|
||||||
|
"Underline": "Underline",
|
||||||
|
"Strike": "Strike",
|
||||||
|
"Code": "Code",
|
||||||
|
"Comment": "Comment",
|
||||||
|
"Text": "Text",
|
||||||
|
"Heading 1": "Heading 1",
|
||||||
|
"Heading 2": "Heading 2",
|
||||||
|
"Heading 3": "Heading 3",
|
||||||
|
"To-do List": "To-do List",
|
||||||
|
"Bullet List": "Bullet List",
|
||||||
|
"Numbered List": "Numbered List",
|
||||||
|
"Blockquote": "Blockquote",
|
||||||
|
"Just start typing with plain text.": "Just start typing with plain text.",
|
||||||
|
"Track tasks with a to-do list.": "Track tasks with a to-do list.",
|
||||||
|
"Big section heading.": "Big section heading.",
|
||||||
|
"Medium section heading.": "Medium section heading.",
|
||||||
|
"Small section heading.": "Small section heading.",
|
||||||
|
"Create a simple bullet list.": "Create a simple bullet list.",
|
||||||
|
"Create a list with numbering.": "Create a list with numbering.",
|
||||||
|
"Create block quote.": "Create block quote.",
|
||||||
|
"Insert code snippet.": "Insert code snippet.",
|
||||||
|
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
||||||
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
|
"Upload any file from your device.": "Upload any file from your device.",
|
||||||
|
"Table": "Table",
|
||||||
|
"Insert a table.": "Insert a table.",
|
||||||
|
"Insert collapsible block.": "Insert collapsible block.",
|
||||||
|
"Video": "Video",
|
||||||
|
"Divider": "Divider",
|
||||||
|
"Quote": "Quote",
|
||||||
|
"Image": "Image",
|
||||||
|
"File attachment": "File attachment",
|
||||||
|
"Toggle block": "Toggle block",
|
||||||
|
"Callout": "Callout",
|
||||||
|
"Insert callout notice.": "Insert callout notice.",
|
||||||
|
"Math inline": "Math inline",
|
||||||
|
"Insert inline math equation.": "Insert inline math equation.",
|
||||||
|
"Math block": "Math block",
|
||||||
|
"Insert math equation": "Insert math equation",
|
||||||
|
"Mermaid diagram": "Mermaid diagram",
|
||||||
|
"Insert mermaid diagram": "Insert mermaid diagram",
|
||||||
|
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
||||||
|
"Insert current date": "Insert current date",
|
||||||
|
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||||
|
"Multiple": "Multiple",
|
||||||
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
|
"Toggle title": "Toggle title",
|
||||||
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands"
|
||||||
|
}
|
||||||
187
apps/client/public/locales/zh-CN/translation.json
Normal file
187
apps/client/public/locales/zh-CN/translation.json
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"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": "例如: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": "发送邀请",
|
||||||
|
"Settings": "设置",
|
||||||
|
"Setup workspace": "设置工作空间",
|
||||||
|
"Sign In": "登录",
|
||||||
|
"Sign Up": "注册",
|
||||||
|
"Slug": "短链接",
|
||||||
|
"Space": "空间",
|
||||||
|
"Space description": "空间描述",
|
||||||
|
"Space menu": "空间菜单",
|
||||||
|
"Space name": "空间名称",
|
||||||
|
"Space settings": "空间设置",
|
||||||
|
"Space slug": "空间短链接",
|
||||||
|
"Spaces": "空间",
|
||||||
|
"Spaces you belong to": "您所属的空间",
|
||||||
|
"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个字符。"
|
||||||
|
}
|
||||||
@ -26,8 +26,10 @@ import { ErrorBoundary } from "react-error-boundary";
|
|||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setSocket] = useAtom(socketAtom);
|
const [, setSocket] = useAtom(socketAtom);
|
||||||
const authToken = useAtomValue(authTokensAtom);
|
const authToken = useAtomValue(authTokensAtom);
|
||||||
|
|
||||||
@ -78,7 +80,7 @@ export default function App() {
|
|||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={<>Failed to load page. An error occurred.</>}
|
fallback={<>{t("Failed to load page. An error occurred.")}</>}
|
||||||
>
|
>
|
||||||
<Page />
|
<Page />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useState } from "react";
|
|||||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { exportSpace } from "@/features/space/services/space-service";
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,6 +30,7 @@ export default function ExportModal({
|
|||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
@ -73,7 +75,7 @@ export default function ExportModal({
|
|||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Format</Text>
|
<Text size="md">{t("Format")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
</Group>
|
</Group>
|
||||||
@ -84,7 +86,7 @@ export default function ExportModal({
|
|||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Include subpages</Text>
|
<Text size="md">{t("Include subpages")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -102,7 +104,7 @@ export default function ExportModal({
|
|||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Include attachments</Text>
|
<Text size="md">{t("Include attachments")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -116,9 +118,9 @@ export default function ExportModal({
|
|||||||
|
|
||||||
<Group justify="center" mt="md">
|
<Group justify="center" mt="md">
|
||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
Cancel
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport}>Export</Button>
|
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
@ -131,6 +133,8 @@ interface ExportFormatSelection {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={[
|
||||||
@ -143,7 +147,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
|||||||
comboboxProps={{ width: "120" }}
|
comboboxProps={{ width: "120" }}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
withCheckIcon={false}
|
withCheckIcon={false}
|
||||||
aria-label="Select export format"
|
aria-label={t("Select export format")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,14 @@ import {formattedDate} from '@/lib/time.ts';
|
|||||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
||||||
import { IconFileDescription } from '@tabler/icons-react';
|
import { IconFileDescription } from '@tabler/icons-react';
|
||||||
import { getSpaceUrl } from '@/lib/config.ts';
|
import { getSpaceUrl } from '@/lib/config.ts';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({spaceId}: Props) {
|
export default function RecentChanges({spaceId}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -26,7 +28,7 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <Text>Failed to fetch recent pages</Text>;
|
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages && pages.items.length > 0 ? (
|
return pages && pages.items.length > 0 ? (
|
||||||
@ -48,7 +50,7 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
{page.title || 'Untitled'}
|
{page.title || t("Untitled")}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@ -78,7 +80,7 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
) : (
|
) : (
|
||||||
<Text size="md" ta="center">
|
<Text size="md" ta="center">
|
||||||
No pages yet
|
{t("No pages yet")}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} 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 SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
|
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
@ -25,7 +27,7 @@ export function AppHeader() {
|
|||||||
|
|
||||||
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}>
|
||||||
{link.label}
|
{t(link.label)}
|
||||||
</Link>
|
</Link>
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -35,10 +37,10 @@ export function AppHeader() {
|
|||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!isHomeRoute && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label="Sidebar toggle">
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
|
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
aria-label="Sidebar toggle"
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={mobileOpened}
|
opened={mobileOpened}
|
||||||
onClick={toggleMobile}
|
onClick={toggleMobile}
|
||||||
hiddenFrom="sm"
|
hiddenFrom="sm"
|
||||||
@ -46,9 +48,9 @@ export function AppHeader() {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Sidebar toggle">
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
aria-label="Sidebar toggle"
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={desktopOpened}
|
opened={desktopOpened}
|
||||||
onClick={toggleDesktop}
|
onClick={toggleDesktop}
|
||||||
visibleFrom="sm"
|
visibleFrom="sm"
|
||||||
|
|||||||
@ -3,9 +3,11 @@ 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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Aside() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
@ -25,7 +27,7 @@ export default function Aside() {
|
|||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
<Text mb="md" fw={500}>
|
<Text mb="md" fw={500}>
|
||||||
{title}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
|
|||||||
@ -13,8 +13,10 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function TopMenu() {
|
export default function TopMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
|
||||||
@ -44,14 +46,14 @@ export default function TopMenu() {
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>Workspace</Menu.Label>
|
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
>
|
>
|
||||||
Workspace settings
|
{t("Workspace settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -59,12 +61,12 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||||
leftSection={<IconUsers size={16} />}
|
leftSection={<IconUsers size={16} />}
|
||||||
>
|
>
|
||||||
Manage members
|
{t("Manage members")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Label>Account</Menu.Label>
|
<Menu.Label>{t("Account")}</Menu.Label>
|
||||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||||
<Group wrap={"nowrap"}>
|
<Group wrap={"nowrap"}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
@ -88,7 +90,7 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||||
leftSection={<IconUserCircle size={16} />}
|
leftSection={<IconUserCircle size={16} />}
|
||||||
>
|
>
|
||||||
My profile
|
{t("My profile")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -96,13 +98,13 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||||
leftSection={<IconBrush size={16} />}
|
leftSection={<IconBrush size={16} />}
|
||||||
>
|
>
|
||||||
My preferences
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
Logout
|
{t("Logout")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -51,6 +52,7 @@ const groupedData: DataGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsSidebar() {
|
export default function SettingsSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -62,7 +64,7 @@ export default function SettingsSidebar() {
|
|||||||
const menuItems = groupedData.map((group) => (
|
const menuItems = groupedData.map((group) => (
|
||||||
<div key={group.heading}>
|
<div key={group.heading}>
|
||||||
<Text c="dimmed" className={classes.linkHeader}>
|
<Text c="dimmed" className={classes.linkHeader}>
|
||||||
{group.heading}
|
{t(group.heading)}
|
||||||
</Text>
|
</Text>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@ -72,7 +74,7 @@ export default function SettingsSidebar() {
|
|||||||
to={item.path}
|
to={item.path}
|
||||||
>
|
>
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{item.label}</span>
|
<span>{t(item.label)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -89,7 +91,7 @@ export default function SettingsSidebar() {
|
|||||||
>
|
>
|
||||||
<IconArrowLeft stroke={2} />
|
<IconArrowLeft stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Text fw={500}>Settings</Text>
|
<Text fw={500}>{t("Settings")}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
|||||||
@ -2,21 +2,24 @@ import { Title, Text, Button, Container, Group } from "@mantine/core";
|
|||||||
import classes from "./error-404.module.css";
|
import classes from "./error-404.module.css";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function Error404() {
|
export function Error404() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>404 page not found - Docmost</title>
|
<title>{t("404 page not found")} - Docmost</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Container className={classes.root}>
|
<Container className={classes.root}>
|
||||||
<Title className={classes.title}>404 Page Not Found</Title>
|
<Title className={classes.title}>{t("404 page not found")}</Title>
|
||||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||||
Sorry, we can't find the page you are looking for.
|
{t("Sorry, we can't find the page you are looking for.")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||||
Take me back to homepage
|
{t("Take me back to homepage")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { forwardRef } from "react";
|
|||||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||||
import { Group, Text, Menu, Button } from "@mantine/core";
|
import { Group, Text, Menu, Button } from "@mantine/core";
|
||||||
import { IRoleData } from "@/lib/types.ts";
|
import { IRoleData } from "@/lib/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||||
name: string;
|
name: string;
|
||||||
@ -36,10 +37,12 @@ export default function RoleSelectMenu({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: RoleMenuProps) {
|
}: RoleMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<RoleButton name={roleName} disabled={disabled} />
|
<RoleButton name={t(roleName)} disabled={disabled} />
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
@ -50,9 +53,9 @@ export default function RoleSelectMenu({
|
|||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{item.label}</Text>
|
<Text size="sm">{t(item.label)}</Text>
|
||||||
<Text size="xs" opacity={0.65}>
|
<Text size="xs" opacity={0.65}>
|
||||||
{item.description}
|
{t(item.description)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{item.label === roleName && <IconCheck size={20} />}
|
{item.label === roleName && <IconCheck size={20} />}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { IForgotPassword } from "@/features/auth/types/auth.types";
|
|||||||
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
|
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
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";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
@ -15,6 +16,7 @@ const formSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function ForgotPasswordForm() {
|
export function ForgotPasswordForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { forgotPassword, isLoading } = useAuth();
|
const { forgotPassword, isLoading } = useAuth();
|
||||||
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
@ -36,7 +38,7 @@ export function ForgotPasswordForm() {
|
|||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} my={40} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" mt={200}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
Forgot password
|
{t("Forgot password")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
@ -53,14 +55,15 @@ export function ForgotPasswordForm() {
|
|||||||
|
|
||||||
{isTokenSent && (
|
{isTokenSent && (
|
||||||
<Text>
|
<Text>
|
||||||
A password reset link has been sent to your email. Please check
|
{t(
|
||||||
your inbox.
|
"A password reset link has been sent to your email. Please check your inbox.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isTokenSent && (
|
{!isTokenSent && (
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
Send reset link
|
{t("Send reset link")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import useAuth from "@/features/auth/hooks/use-auth";
|
|||||||
import classes from "@/features/auth/components/auth.module.css";
|
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";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -26,6 +27,7 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function InviteSignUpForm() {
|
export function InviteSignUpForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -55,7 +57,7 @@ export function InviteSignUpForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div>invalid invitation link</div>;
|
return <div>{t("invalid invitation link")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
@ -66,7 +68,7 @@ export function InviteSignUpForm() {
|
|||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} my={40} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" mt={200}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
Join the workspace
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
@ -74,8 +76,8 @@ export function InviteSignUpForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
label="Name"
|
label={t("Name")}
|
||||||
placeholder="enter your full name"
|
placeholder={t("enter your full name")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
@ -83,7 +85,7 @@ export function InviteSignUpForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={t("Email")}
|
||||||
value={invitation.email}
|
value={invitation.email}
|
||||||
disabled
|
disabled
|
||||||
variant="filled"
|
variant="filled"
|
||||||
@ -91,14 +93,14 @@ export function InviteSignUpForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
placeholder="Your password"
|
placeholder={t("Your password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
Sign Up
|
{t("Sign Up")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -9,13 +9,13 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
|
|
||||||
Anchor,
|
Anchor,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
@ -26,6 +26,7 @@ const formSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { signIn, isLoading } = useAuth();
|
const { signIn, isLoading } = useAuth();
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
|
||||||
@ -45,29 +46,29 @@ export function LoginForm() {
|
|||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} my={40} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" mt={200}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
Login
|
{t("Login")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={t("Email")}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
placeholder="Your password"
|
placeholder={t("Your password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
Sign In
|
{t("Sign In")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -77,7 +78,7 @@ export function LoginForm() {
|
|||||||
underline="never"
|
underline="never"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Forgot your password?
|
{t("Forgot your password?")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
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";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
newPassword: z
|
newPassword: z
|
||||||
@ -24,6 +25,7 @@ interface PasswordResetFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { passwordReset, isLoading } = useAuth();
|
const { passwordReset, isLoading } = useAuth();
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
|
||||||
@ -37,28 +39,28 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
|||||||
async function onSubmit(data: IPasswordReset) {
|
async function onSubmit(data: IPasswordReset) {
|
||||||
await passwordReset({
|
await passwordReset({
|
||||||
token: resetToken,
|
token: resetToken,
|
||||||
newPassword: data.newPassword
|
newPassword: data.newPassword,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} my={40} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" mt={200}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
Password reset
|
{t("Password reset")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="New password"
|
label={t("New password")}
|
||||||
placeholder="Your new password"
|
placeholder={t("Your new password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
Set password
|
{t("Set password")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().min(3).max(50),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
@ -25,6 +26,7 @@ const formSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function SetupWorkspaceForm() {
|
export function SetupWorkspaceForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { setupWorkspace, isLoading } = useAuth();
|
const { setupWorkspace, isLoading } = useAuth();
|
||||||
// useRedirectIfAuthenticated();
|
// useRedirectIfAuthenticated();
|
||||||
|
|
||||||
@ -46,15 +48,15 @@ export function SetupWorkspaceForm() {
|
|||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} my={40} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" mt={200}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
Create workspace
|
{t("Create workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="workspaceName"
|
id="workspaceName"
|
||||||
type="text"
|
type="text"
|
||||||
label="Workspace Name"
|
label={t("Workspace Name")}
|
||||||
placeholder="e.g ACME Inc"
|
placeholder={t("e.g ACME Inc")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("workspaceName")}
|
{...form.getInputProps("workspaceName")}
|
||||||
@ -63,8 +65,8 @@ export function SetupWorkspaceForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
label="Your Name"
|
label={t("Your Name")}
|
||||||
placeholder="enter your full name"
|
placeholder={t("enter your full name")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
@ -73,7 +75,7 @@ export function SetupWorkspaceForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
label="Your Email"
|
label={t("Your Email")}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
@ -81,14 +83,14 @@ export function SetupWorkspaceForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
placeholder="Enter a strong password"
|
placeholder={t("Enter a strong password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
Setup workspace
|
{t("Setup workspace")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Button, Group } from '@mantine/core';
|
import { Button, Group } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type CommentActionsProps = {
|
type CommentActionsProps = {
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@ -6,9 +7,13 @@ type CommentActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="flex-end" pt={2} wrap="nowrap">
|
<Group justify="flex-end" pt={2} wrap="nowrap">
|
||||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
|
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { useCreateCommentMutation } from "@/features/comment/queries/comment-que
|
|||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CommentDialogProps {
|
interface CommentDialogProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@ -21,6 +22,7 @@ interface CommentDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
@ -107,7 +109,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
|
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
onUpdate={handleCommentEditorChange}
|
onUpdate={handleCommentEditorChange}
|
||||||
placeholder="Write a comment"
|
placeholder={t("Write a comment")}
|
||||||
editable={true}
|
editable={true}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import classes from "./comment.module.css";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
@ -27,6 +28,7 @@ const CommentEditor = forwardRef(
|
|||||||
}: CommentEditorProps,
|
}: CommentEditorProps,
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { ref: focusRef, focused } = useFocusWithin();
|
const { ref: focusRef, focused } = useFocusWithin();
|
||||||
|
|
||||||
const commentEditor = useEditor({
|
const commentEditor = useEditor({
|
||||||
@ -36,7 +38,7 @@ const CommentEditor = forwardRef(
|
|||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder || "Reply...",
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link,
|
Link,
|
||||||
|
|||||||
@ -24,7 +24,6 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
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);
|
||||||
|
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
useCommentsQuery,
|
useCommentsQuery,
|
||||||
useCreateCommentMutation,
|
useCreateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
@ -14,8 +13,10 @@ import { IComment } from "@/features/comment/types/comment.types.ts";
|
|||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function CommentList() {
|
function CommentList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const {
|
const {
|
||||||
@ -79,11 +80,11 @@ function CommentList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div>Error loading comments.</div>;
|
return <div>{t("Error loading comments.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!comments || comments.items.length === 0) {
|
if (!comments || comments.items.length === 0) {
|
||||||
return <>No comments yet.</>;
|
return <>{t("No comments yet.")}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ActionIcon, Menu } from '@mantine/core';
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash } 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";
|
||||||
|
|
||||||
type CommentMenuProps = {
|
type CommentMenuProps = {
|
||||||
onEditComment: () => void;
|
onEditComment: () => void;
|
||||||
@ -8,34 +9,35 @@ type CommentMenuProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: 'Are you sure you want to delete this comment?',
|
title: t("Are you sure you want to delete this comment?"),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: 'red' },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: onDeleteComment,
|
onConfirm: onDeleteComment,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={onEditComment}
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
leftSection={<IconEdit size={14} />}>
|
{t("Edit comment")}
|
||||||
Edit comment
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconTrash size={14} />}
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
onClick={openDeleteModal}
|
onClick={openDeleteModal}
|
||||||
>
|
>
|
||||||
Delete comment
|
{t("Delete comment")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -1,34 +1,44 @@
|
|||||||
import { ActionIcon } from '@mantine/core';
|
import { ActionIcon } from "@mantine/core";
|
||||||
import { IconCircleCheck } from '@tabler/icons-react';
|
import { IconCircleCheck } from "@tabler/icons-react";
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from "@mantine/modals";
|
||||||
import { useResolveCommentMutation } from '@/features/comment/queries/comment-query';
|
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
|
||||||
const isResolved = resolvedAt != null;
|
const isResolved = resolvedAt != null;
|
||||||
const iconColor = isResolved ? 'green' : 'gray';
|
const iconColor = isResolved ? "green" : "gray";
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openConfirmModal = () =>
|
const openConfirmModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: 'Are you sure you want to resolve this comment thread?',
|
title: t("Are you sure you want to resolve this comment thread?"),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: 'Confirm', cancel: 'Cancel' },
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
onConfirm: handleResolveToggle,
|
onConfirm: handleResolveToggle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResolveToggle = async () => {
|
const handleResolveToggle = async () => {
|
||||||
try {
|
try {
|
||||||
await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
|
await resolveCommentMutation.mutateAsync({
|
||||||
|
commentId,
|
||||||
|
resolved: !isResolved,
|
||||||
|
});
|
||||||
//TODO: remove comment mark
|
//TODO: remove comment mark
|
||||||
// Remove comment thread from state on resolve
|
// Remove comment thread from state on resolve
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle resolved state:', error);
|
console.error("Failed to toggle resolved state:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
|
<ActionIcon
|
||||||
|
onClick={openConfirmModal}
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ export function useCommentsQuery(
|
|||||||
|
|
||||||
export function useCreateCommentMutation() {
|
export function useCreateCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
mutationFn: (data) => createComment(data),
|
mutationFn: (data) => createComment(data),
|
||||||
@ -45,28 +47,37 @@ export function useCreateCommentMutation() {
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
||||||
notifications.show({ message: "Comment created successfully" });
|
notifications.show({ message: t("Comment created successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Error creating comment", color: "red" });
|
notifications.show({
|
||||||
|
message: t("Error creating comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateCommentMutation() {
|
export function useUpdateCommentMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
mutationFn: (data) => updateComment(data),
|
mutationFn: (data) => updateComment(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
notifications.show({ message: "Comment updated successfully" });
|
notifications.show({ message: t("Comment updated successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Failed to update comment", color: "red" });
|
notifications.show({
|
||||||
|
message: t("Failed to update comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteCommentMutation(pageId?: string) {
|
export function useDeleteCommentMutation(pageId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (commentId: string) => deleteComment(commentId),
|
mutationFn: (commentId: string) => deleteComment(commentId),
|
||||||
@ -86,16 +97,20 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.show({ message: "Comment deleted successfully" });
|
notifications.show({ message: t("Comment deleted successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Failed to delete comment", color: "red" });
|
notifications.show({
|
||||||
|
message: t("Failed to delete comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
@ -114,11 +129,11 @@ export function useResolveCommentMutation() {
|
|||||||
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
notifications.show({ message: "Comment resolved successfully" });
|
notifications.show({ message: t("Comment resolved successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Failed to resolve comment",
|
message: t("Failed to resolve comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
export const uploadAttachmentAction = handleAttachmentUpload({
|
export const uploadAttachmentAction = handleAttachmentUpload({
|
||||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
@ -23,7 +24,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
|
|||||||
if (file.size > getFileUploadSizeLimit()) {
|
if (file.size > getFileUploadSizeLimit()) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: "red",
|
color: "red",
|
||||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||||
|
limit: formatBytes(getFileUploadSizeLimit()),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { useAtom } from "jotai";
|
|||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -39,6 +40,7 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
@ -49,31 +51,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
|
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: "bold",
|
name: "Bold",
|
||||||
isActive: () => props.editor.isActive("bold"),
|
isActive: () => props.editor.isActive("bold"),
|
||||||
command: () => props.editor.chain().focus().toggleBold().run(),
|
command: () => props.editor.chain().focus().toggleBold().run(),
|
||||||
icon: IconBold,
|
icon: IconBold,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "italic",
|
name: "Italic",
|
||||||
isActive: () => props.editor.isActive("italic"),
|
isActive: () => props.editor.isActive("italic"),
|
||||||
command: () => props.editor.chain().focus().toggleItalic().run(),
|
command: () => props.editor.chain().focus().toggleItalic().run(),
|
||||||
icon: IconItalic,
|
icon: IconItalic,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "underline",
|
name: "Underline",
|
||||||
isActive: () => props.editor.isActive("underline"),
|
isActive: () => props.editor.isActive("underline"),
|
||||||
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
||||||
icon: IconUnderline,
|
icon: IconUnderline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "strike",
|
name: "Strike",
|
||||||
isActive: () => props.editor.isActive("strike"),
|
isActive: () => props.editor.isActive("strike"),
|
||||||
command: () => props.editor.chain().focus().toggleStrike().run(),
|
command: () => props.editor.chain().focus().toggleStrike().run(),
|
||||||
icon: IconStrikethrough,
|
icon: IconStrikethrough,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "code",
|
name: "Code",
|
||||||
isActive: () => props.editor.isActive("code"),
|
isActive: () => props.editor.isActive("code"),
|
||||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||||
icon: IconCode,
|
icon: IconCode,
|
||||||
@ -81,7 +83,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const commentItem: BubbleMenuItem = {
|
const commentItem: BubbleMenuItem = {
|
||||||
name: "comment",
|
name: "Comment",
|
||||||
isActive: () => props.editor.isActive("comment"),
|
isActive: () => props.editor.isActive("comment"),
|
||||||
command: () => {
|
command: () => {
|
||||||
const commentId = uuid7();
|
const commentId = uuid7();
|
||||||
@ -138,13 +140,13 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
|
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<Tooltip key={index} label={item.name} withArrow>
|
<Tooltip key={index} label={t(item.name)} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
key={index}
|
key={index}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="0"
|
radius="0"
|
||||||
aria-label={item.name}
|
aria-label={t(item.name)}
|
||||||
className={clsx({ [classes.active]: item.isActive() })}
|
className={clsx({ [classes.active]: item.isActive() })}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
onClick={item.command}
|
onClick={item.command}
|
||||||
@ -175,7 +177,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="0"
|
radius="0"
|
||||||
aria-label={commentItem.name}
|
aria-label={t(commentItem.name)}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
onClick={commentItem.command}
|
onClick={commentItem.command}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface BubbleColorMenuItem {
|
export interface BubbleColorMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -106,6 +107,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||||
editor.isActive("textStyle", { color }),
|
editor.isActive("textStyle", { color }),
|
||||||
);
|
);
|
||||||
@ -117,7 +119,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover width={200} opened={isOpen} withArrow>
|
<Popover width={200} opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label="Text color" withArrow>
|
<Tooltip label={t("Text color")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
@ -136,8 +138,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
{/* make mah responsive */}
|
{/* make mah responsive */}
|
||||||
<ScrollArea.Autosize type="scroll" mah="400">
|
<ScrollArea.Autosize type="scroll" mah="400">
|
||||||
<Text span c="dimmed" inherit>
|
<Text span c="dimmed" tt="uppercase" inherit>
|
||||||
COLOR
|
{t("Color")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button.Group orientation="vertical">
|
<Button.Group orientation="vertical">
|
||||||
@ -155,7 +157,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.commands.unsetColor();
|
editor.commands.unsetColor();
|
||||||
name !== "Default" &&
|
name !== t("Default") &&
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
@ -165,7 +167,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
{name}
|
{t(name)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Button.Group>
|
</Button.Group>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { IconLink } from "@tabler/icons-react";
|
|||||||
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface LinkSelectorProps {
|
interface LinkSelectorProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@ -15,6 +16,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const onLink = useCallback(
|
const onLink = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@ -32,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
|||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label="Add link" withArrow>
|
<Tooltip label={t("Add link")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea } from "@mantine/core";
|
import { Popover, Button, ScrollArea } from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@ -33,6 +34,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: "Text",
|
name: "Text",
|
||||||
@ -114,7 +117,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
rightSection={<IconChevronDown size={16} />}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
{activeItem?.name}
|
{t(activeItem?.name)}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
@ -137,7 +140,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
{item.name}
|
{t(item.name)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Button.Group>
|
</Button.Group>
|
||||||
|
|||||||
@ -17,8 +17,10 @@ import {
|
|||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType } from "@docmost/editor-ext";
|
import { CalloutType } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -71,11 +73,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label="Info">
|
<Tooltip position="top" label={t("Info")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Info"
|
aria-label={t("Info")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("callout", { type: "info" }) ? "light" : "default"
|
editor.isActive("callout", { type: "info" }) ? "light" : "default"
|
||||||
}
|
}
|
||||||
@ -84,11 +86,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Success">
|
<Tooltip position="top" label={t("Success")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Success"
|
aria-label={t("Success")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("callout", { type: "success" })
|
editor.isActive("callout", { type: "success" })
|
||||||
? "light"
|
? "light"
|
||||||
@ -99,11 +101,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Warning">
|
<Tooltip position="top" label={t("Warning")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Warning"
|
aria-label={t("Warning")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("callout", { type: "warning" })
|
editor.isActive("callout", { type: "warning" })
|
||||||
? "light"
|
? "light"
|
||||||
@ -114,11 +116,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Danger">
|
<Tooltip position="top" label={t("Danger")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Danger"
|
aria-label={t("Danger")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("callout", { type: "danger" })
|
editor.isActive("callout", { type: "danger" })
|
||||||
? "light"
|
? "light"
|
||||||
|
|||||||
@ -2,16 +2,17 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
|||||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||||
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
|
|
||||||
import classes from './code-block.module.css';
|
import classes from './code-block.module.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const MermaidView = React.lazy(
|
const MermaidView = React.lazy(
|
||||||
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function CodeBlockView(props: NodeViewProps) {
|
export default function CodeBlockView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, extension, editor, getPos } = props;
|
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||||
const { language } = node.attrs;
|
const { language } = node.attrs;
|
||||||
const [languageValue, setLanguageValue] = useState<string | null>(
|
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||||
@ -61,7 +62,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
<CopyButton value={node?.textContent} timeout={2000}>
|
<CopyButton value={node?.textContent} timeout={2000}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={copied ? 'Copied' : 'Copy'}
|
label={copied ? t('Copied') : t('Copy')}
|
||||||
withArrow
|
withArrow
|
||||||
position="right"
|
position="right"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
@ -29,11 +30,11 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (props.editor.isEditable) {
|
if (props.editor.isEditable) {
|
||||||
setPreview(
|
setPreview(
|
||||||
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
|
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setPreview(
|
setPreview(
|
||||||
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
|
`<div class="${classes.error}">${t("Invalid Mermaid diagram")}</div>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,25 +1,34 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@mantine/core';
|
import {
|
||||||
import { useRef, useState } from 'react';
|
ActionIcon,
|
||||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
Card,
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
Image,
|
||||||
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
|
Modal,
|
||||||
|
Text,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
EventExit,
|
EventExit,
|
||||||
EventSave,
|
EventSave,
|
||||||
} from 'react-drawio';
|
} from "react-drawio";
|
||||||
import { IAttachment } from '@/lib/types';
|
import { IAttachment } from "@/lib/types";
|
||||||
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
import clsx from 'clsx';
|
import clsx from "clsx";
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function DrawioView(props: NodeViewProps) {
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
const [initialXML, setInitialXML] = useState<string>('');
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
@ -32,15 +41,15 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
if (src) {
|
if (src) {
|
||||||
const url = getFileUrl(src);
|
const url = getFileUrl(src);
|
||||||
const request = await fetch(url, {
|
const request = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
cache: 'no-store',
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const blob = await request.blob();
|
const blob = await request.blob();
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
const base64data = (reader.result || '') as string;
|
const base64data = (reader.result || "") as string;
|
||||||
setInitialXML(base64data);
|
setInitialXML(base64data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -54,7 +63,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const handleSave = async (data: EventSave) => {
|
const handleSave = async (data: EventSave) => {
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
const fileName = 'diagram.drawio.svg';
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
@ -81,15 +90,15 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<div style={{ height: '100vh' }}>
|
<div style={{ height: "100vh" }}>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
xml={initialXML}
|
xml={initialXML}
|
||||||
baseUrl={getDrawioUrl()}
|
baseUrl={getDrawioUrl()}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
|
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||||
spin: true,
|
spin: true,
|
||||||
libraries: true,
|
libraries: true,
|
||||||
saveAndExit: true,
|
saveAndExit: true,
|
||||||
@ -97,7 +106,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
}}
|
}}
|
||||||
onSave={(data: EventSave) => {
|
onSave={(data: EventSave) => {
|
||||||
// If the save is triggered by another event, then do nothing
|
// If the save is triggered by another event, then do nothing
|
||||||
if (data.parentEvent !== 'save') {
|
if (data.parentEvent !== "save") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleSave(data);
|
handleSave(data);
|
||||||
@ -116,7 +125,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
{src ? (
|
{src ? (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: "relative" }}>
|
||||||
<Image
|
<Image
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -125,8 +134,8 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
src={getFileUrl(src)}
|
src={getFileUrl(src)}
|
||||||
alt={title}
|
alt={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
selected ? 'ProseMirror-selectednode' : '',
|
selected ? "ProseMirror-selectednode" : "",
|
||||||
'alignCenter'
|
"alignCenter",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -137,7 +146,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
mx="xs"
|
mx="xs"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
}}
|
}}
|
||||||
@ -152,20 +161,20 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
withBorder
|
withBorder
|
||||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<ActionIcon variant="transparent" color="gray">
|
<ActionIcon variant="transparent" color="gray">
|
||||||
<IconEdit size={18} />
|
<IconEdit size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
Double-click to edit drawio diagram
|
{t("Double-click to edit Draw.io diagram")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,22 +1,37 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ActionIcon, AspectRatio, Button, Card, FocusTrap, Group, Popover, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
AspectRatio,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FocusTrap,
|
||||||
|
Group,
|
||||||
|
Popover,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider
|
getEmbedUrlAndProvider,
|
||||||
} from "@/features/editor/components/embed/providers.ts";
|
} from "@/features/editor/components/embed/providers.ts";
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
.string().trim().url({ message: 'please enter a valid url' }),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.url({ message: i18n.t("Please enter a valid url") }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes } = props;
|
const { node, selected, updateAttributes } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
@ -41,9 +56,9 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
updateAttributes({ src: data.url });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: `Invalid ${provider} embed link`,
|
message: t("Invalid {{provider}} embed link", { provider: provider }),
|
||||||
position: 'top-right',
|
position: "top-right",
|
||||||
color: 'red'
|
color: "red",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,7 +77,6 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
></iframe>
|
></iframe>
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||||
@ -71,20 +85,22 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
radius="md"
|
radius="md"
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
withBorder
|
withBorder
|
||||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<ActionIcon variant="transparent" color="gray">
|
<ActionIcon variant="transparent" color="gray">
|
||||||
<IconEdit size={18} />
|
<IconEdit size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
Embed {getEmbedProviderById(provider).name}
|
{t("Embed {{provider}}", {
|
||||||
|
provider: getEmbedProviderById(provider).name,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@ -92,15 +108,18 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
<Popover.Dropdown bg="var(--mantine-color-body)">
|
<Popover.Dropdown bg="var(--mantine-color-body)">
|
||||||
<form onSubmit={embedForm.onSubmit(onSubmit)}>
|
<form onSubmit={embedForm.onSubmit(onSubmit)}>
|
||||||
<FocusTrap active={true}>
|
<FocusTrap active={true}>
|
||||||
<TextInput placeholder={`Enter ${getEmbedProviderById(provider).name} link to embed`}
|
<TextInput
|
||||||
key={embedForm.key('url')}
|
placeholder={t("Enter {{provider}} link to embed", {
|
||||||
{... embedForm.getInputProps('url')}
|
provider: getEmbedProviderById(provider).name,
|
||||||
|
})}
|
||||||
|
key={embedForm.key("url")}
|
||||||
|
{...embedForm.getInputProps("url")}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
/>
|
/>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|
||||||
<Group justify="center" mt="xs">
|
<Group justify="center" mt="xs">
|
||||||
<Button type="submit">Embed link</Button>
|
<Button type="submit">{t("Embed link")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
@ -7,27 +7,29 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from '@/lib';
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from '@/lib/config.ts';
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
|
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||||
import { IAttachment } from '@/lib/types';
|
import { IAttachment } from "@/lib/types";
|
||||||
import ReactClearModal from 'react-clear-modal';
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from 'clsx';
|
import clsx from "clsx";
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { lazy } from 'react';
|
import { lazy } from "react";
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import('@excalidraw/excalidraw').then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
default: module.Excalidraw,
|
default: module.Excalidraw,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function ExcalidrawView(props: NodeViewProps) {
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
|
|
||||||
@ -46,11 +48,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
if (src) {
|
if (src) {
|
||||||
const url = getFileUrl(src);
|
const url = getFileUrl(src);
|
||||||
const request = await fetch(url, {
|
const request = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
cache: 'no-store',
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loadFromBlob } = await import('@excalidraw/excalidraw');
|
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
setExcalidrawData(data);
|
setExcalidrawData(data);
|
||||||
@ -67,7 +69,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exportToSvg } = await import('@excalidraw/excalidraw');
|
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: excalidrawAPI?.getSceneElements(),
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
@ -83,10 +85,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
|
|
||||||
svgString = svgString.replace(
|
svgString = svgString.replace(
|
||||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||||
'https://unpkg.com/@excalidraw/excalidraw@latest'
|
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileName = 'diagram.excalidraw.svg';
|
const fileName = "diagram.excalidraw.svg";
|
||||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
@ -112,7 +114,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<ReactClearModal
|
<ReactClearModal
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
zIndex: 200,
|
zIndex: 200,
|
||||||
}}
|
}}
|
||||||
@ -122,7 +124,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
contentProps={{
|
contentProps={{
|
||||||
style: {
|
style: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
width: '90vw',
|
width: "90vw",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -132,14 +134,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
bg="var(--mantine-color-body)"
|
bg="var(--mantine-color-body)"
|
||||||
p="xs"
|
p="xs"
|
||||||
>
|
>
|
||||||
<Button onClick={handleSave} size={'compact-sm'}>
|
<Button onClick={handleSave} size={"compact-sm"}>
|
||||||
Save & Exit
|
{t("Save & Exit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={close} color="red" size={'compact-sm'}>
|
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||||
Exit
|
{t("Exit")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: '90vh' }}>
|
<div style={{ height: "90vh" }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
@ -154,7 +156,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</ReactClearModal>
|
</ReactClearModal>
|
||||||
|
|
||||||
{src ? (
|
{src ? (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: "relative" }}>
|
||||||
<Image
|
<Image
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -163,8 +165,8 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
src={getFileUrl(src)}
|
src={getFileUrl(src)}
|
||||||
alt={title}
|
alt={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
selected ? 'ProseMirror-selectednode' : '',
|
selected ? "ProseMirror-selectednode" : "",
|
||||||
'alignCenter'
|
"alignCenter",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -175,7 +177,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
mx="xs"
|
mx="xs"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
}}
|
}}
|
||||||
@ -190,20 +192,20 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
withBorder
|
withBorder
|
||||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<ActionIcon variant="transparent" color="gray">
|
<ActionIcon variant="transparent" color="gray">
|
||||||
<IconEdit size={18} />
|
<IconEdit size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
Double-click to edit Excalidraw diagram
|
{t("Double-click to edit Excalidraw diagram")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -17,8 +17,10 @@ import {
|
|||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -96,11 +98,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label="Align image left">
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align image left"
|
aria-label={t("Align left")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("image", { align: "left" }) ? "light" : "default"
|
editor.isActive("image", { align: "left" }) ? "light" : "default"
|
||||||
}
|
}
|
||||||
@ -109,11 +111,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Align image center">
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align image center"
|
aria-label={t("Align center")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("image", { align: "center" })
|
editor.isActive("image", { align: "center" })
|
||||||
? "light"
|
? "light"
|
||||||
@ -124,11 +126,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Align image right">
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align image right"
|
aria-label={t("Align right")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("image", { align: "right" }) ? "light" : "default"
|
editor.isActive("image", { align: "right" }) ? "light" : "default"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
export const uploadImageAction = handleImageUpload({
|
export const uploadImageAction = handleImageUpload({
|
||||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
@ -23,7 +24,9 @@ export const uploadImageAction = handleImageUpload({
|
|||||||
if (file.size > getFileUploadSizeLimit()) {
|
if (file.size > getFileUploadSizeLimit()) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: "red",
|
color: "red",
|
||||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||||
|
limit: formatBytes(getFileUploadSizeLimit()),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import { Button, Group, TextInput } from "@mantine/core";
|
|||||||
import { IconLink } from "@tabler/icons-react";
|
import { IconLink } from "@tabler/icons-react";
|
||||||
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
|
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
|
||||||
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
|
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const LinkEditorPanel = ({
|
export const LinkEditorPanel = ({
|
||||||
onSetLink,
|
onSetLink,
|
||||||
initialUrl,
|
initialUrl,
|
||||||
}: LinkEditorPanelProps) => {
|
}: LinkEditorPanelProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = useLinkEditorState({
|
const state = useLinkEditorState({
|
||||||
onSetLink,
|
onSetLink,
|
||||||
initialUrl,
|
initialUrl,
|
||||||
@ -20,12 +22,12 @@ export const LinkEditorPanel = ({
|
|||||||
<TextInput
|
<TextInput
|
||||||
leftSection={<IconLink size={16} />}
|
leftSection={<IconLink size={16} />}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="Paste link"
|
placeholder={t("Paste link")}
|
||||||
value={state.url}
|
value={state.url}
|
||||||
onChange={state.onChange}
|
onChange={state.onChange}
|
||||||
/>
|
/>
|
||||||
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
|
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
|
||||||
Save
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type LinkPreviewPanelProps = {
|
export type LinkPreviewPanelProps = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -19,6 +20,8 @@ export const LinkPreviewPanel = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
url,
|
url,
|
||||||
}: LinkPreviewPanelProps) => {
|
}: LinkPreviewPanelProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
||||||
@ -42,13 +45,13 @@ export const LinkPreviewPanel = ({
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<Divider mx={4} orientation="vertical" />
|
<Divider mx={4} orientation="vertical" />
|
||||||
|
|
||||||
<Tooltip label="Edit link">
|
<Tooltip label={t("Edit link")}>
|
||||||
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
|
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Remove link">
|
<Tooltip label={t("Remove link")}>
|
||||||
<ActionIcon onClick={onClear} variant="subtle" color="red">
|
<ActionIcon onClick={onClear} variant="subtle" color="red">
|
||||||
<IconLinkOff size={16} />
|
<IconLinkOff size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import classes from "./math.module.css";
|
|||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
import { IconTrashX } from "@tabler/icons-react";
|
import { IconTrashX } from "@tabler/icons-react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function MathBlockView(props: NodeViewProps) {
|
export default function MathBlockView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, getPos } = props;
|
const { node, updateAttributes, editor, getPos } = props;
|
||||||
const mathResultContainer = useRef<HTMLDivElement>(null);
|
const mathResultContainer = useRef<HTMLDivElement>(null);
|
||||||
const mathPreviewContainer = useRef<HTMLDivElement>(null);
|
const mathPreviewContainer = useRef<HTMLDivElement>(null);
|
||||||
@ -94,9 +96,9 @@ export default function MathBlockView(props: NodeViewProps) {
|
|||||||
></div>
|
></div>
|
||||||
{((isEditing && !preview?.trim().length) ||
|
{((isEditing && !preview?.trim().length) ||
|
||||||
(!isEditing && !node.attrs.text.trim().length)) && (
|
(!isEditing && !node.attrs.text.trim().length)) && (
|
||||||
<div>Empty equation</div>
|
<div>{t("Empty equation")}</div>
|
||||||
)}
|
)}
|
||||||
{error && <div>Invalid equation</div>}
|
{error && <div>{t("Invalid equation")}</div>}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import { Popover, Textarea } from "@mantine/core";
|
import { Popover, Textarea } from "@mantine/core";
|
||||||
import classes from "./math.module.css";
|
import classes from "./math.module.css";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function MathInlineView(props: NodeViewProps) {
|
export default function MathInlineView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, getPos } = props;
|
const { node, updateAttributes, editor, getPos } = props;
|
||||||
const mathResultContainer = useRef<HTMLDivElement>(null);
|
const mathResultContainer = useRef<HTMLDivElement>(null);
|
||||||
const mathPreviewContainer = useRef<HTMLDivElement>(null);
|
const mathPreviewContainer = useRef<HTMLDivElement>(null);
|
||||||
@ -84,9 +86,9 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
></div>
|
></div>
|
||||||
{((isEditing && !preview?.trim().length) ||
|
{((isEditing && !preview?.trim().length) ||
|
||||||
(!isEditing && !node.attrs.text.trim().length)) && (
|
(!isEditing && !node.attrs.text.trim().length)) && (
|
||||||
<div>Empty equation</div>
|
<div>{t("Empty equation")}</div>
|
||||||
)}
|
)}
|
||||||
{error && <div>Invalid equation</div>}
|
{error && <div>{t("Invalid equation")}</div>}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown p={"xs"}>
|
<Popover.Dropdown p={"xs"}>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./slash-menu.module.css";
|
import classes from "./slash-menu.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const CommandList = ({
|
const CommandList = ({
|
||||||
items,
|
items,
|
||||||
@ -25,6 +26,7 @@ const CommandList = ({
|
|||||||
editor: any;
|
editor: any;
|
||||||
range: any;
|
range: any;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -104,18 +106,17 @@ const CommandList = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
component="div"
|
component="div"
|
||||||
aria-label={item.title}
|
|
||||||
>
|
>
|
||||||
<item.icon size={18} />
|
<item.icon size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{item.title}
|
{t(item.title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{item.description}
|
{t(item.description)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import {
|
|||||||
IconRowRemove,
|
IconRowRemove,
|
||||||
IconSquareToggle,
|
IconSquareToggle,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ view, state, from }: ShouldShowProps) => {
|
({ view, state, from }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -58,45 +60,45 @@ export const TableCellMenu = React.memo(
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label="Merge cells">
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Merge cells"
|
aria-label={t("Merge cells")}
|
||||||
>
|
>
|
||||||
<IconBoxMargin size={18} />
|
<IconBoxMargin size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Split cell">
|
<Tooltip position="top" label={t("Split cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={splitCell}
|
onClick={splitCell}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Split cell"
|
aria-label={t("Split cell")}
|
||||||
>
|
>
|
||||||
<IconSquareToggle size={18} />
|
<IconSquareToggle size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Delete column">
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Delete column"
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
<IconColumnRemove size={18} />
|
<IconColumnRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Delete row">
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Delete row"
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
<IconRowRemove size={18} />
|
<IconRowRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@ -21,9 +21,11 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const TableMenu = React.memo(
|
export const TableMenu = React.memo(
|
||||||
({ editor }: EditorMenuProps): JSX.Element => {
|
({ editor }: EditorMenuProps): JSX.Element => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -111,79 +113,80 @@ export const TableMenu = React.memo(
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label="Add left column">
|
<Tooltip position="top" label={t("Add left column")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Add left column"
|
aria-label={t("Add left column")}
|
||||||
>
|
>
|
||||||
<IconColumnInsertLeft size={18} />
|
<IconColumnInsertLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Add right column">
|
<Tooltip position="top" label={t("Add right column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnRight}
|
onClick={addColumnRight}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Add right column"
|
aria-label={t("Add right column")}
|
||||||
>
|
>
|
||||||
<IconColumnInsertRight size={18} />
|
<IconColumnInsertRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Delete column">
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Delete column"
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
<IconColumnRemove size={18} />
|
<IconColumnRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Add row above">
|
<Tooltip position="top" label={t("Add row above")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowAbove}
|
onClick={addRowAbove}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Add row above"
|
aria-label={t("Add row above")}
|
||||||
>
|
>
|
||||||
<IconRowInsertTop size={18} />
|
<IconRowInsertTop size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Add row below">
|
<Tooltip position="top" label={t("Add row below")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowBelow}
|
onClick={addRowBelow}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Add row below"
|
aria-label={t("Add row below")}
|
||||||
>
|
>
|
||||||
<IconRowInsertBottom size={18} />
|
<IconRowInsertBottom size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Delete row">
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Delete row"
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
<IconRowRemove size={18} />
|
<IconRowRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Delete table">
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
color="red"
|
color="red"
|
||||||
aria-label="Delete table"
|
aria-label={t("Delete table")}
|
||||||
>
|
>
|
||||||
<IconTrashX size={18} />
|
<IconTrashX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
export const uploadVideoAction = handleVideoUpload({
|
export const uploadVideoAction = handleVideoUpload({
|
||||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
@ -24,11 +25,12 @@ export const uploadVideoAction = handleVideoUpload({
|
|||||||
if (file.size > getFileUploadSizeLimit()) {
|
if (file.size > getFileUploadSizeLimit()) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: "red",
|
color: "red",
|
||||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||||
|
limit: formatBytes(getFileUploadSizeLimit()),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,8 +17,10 @@ import {
|
|||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -96,11 +98,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label="Align video left">
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoLeft}
|
onClick={alignVideoLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align video left"
|
aria-label={t("Align left")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("video", { align: "left" }) ? "light" : "default"
|
editor.isActive("video", { align: "left" }) ? "light" : "default"
|
||||||
}
|
}
|
||||||
@ -109,11 +111,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Align video center">
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoCenter}
|
onClick={alignVideoCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align video center"
|
aria-label={t("Align center")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("video", { align: "center" })
|
editor.isActive("video", { align: "center" })
|
||||||
? "light"
|
? "light"
|
||||||
@ -124,11 +126,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label="Align video right">
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoRight}
|
onClick={alignVideoRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label="Align video right"
|
aria-label={t("Align right")}
|
||||||
variant={
|
variant={
|
||||||
editor.isActive("video", { align: "right" }) ? "light" : "default"
|
editor.isActive("video", { align: "right" }) ? "light" : "default"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import {
|
|||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed
|
Embed,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@ -64,6 +64,7 @@ import clojure from "highlight.js/lib/languages/clojure";
|
|||||||
import fortran from "highlight.js/lib/languages/fortran";
|
import fortran from "highlight.js/lib/languages/fortran";
|
||||||
import haskell from "highlight.js/lib/languages/haskell";
|
import haskell from "highlight.js/lib/languages/haskell";
|
||||||
import scala from "highlight.js/lib/languages/scala";
|
import scala from "highlight.js/lib/languages/scala";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@ -94,13 +95,13 @@ export const mainExtensions = [
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return `Heading ${node.attrs.level}`;
|
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||||
}
|
}
|
||||||
if (node.type.name === "detailsSummary") {
|
if (node.type.name === "detailsSummary") {
|
||||||
return "Toggle title";
|
return i18n.t("Toggle title");
|
||||||
}
|
}
|
||||||
if (node.type.name === "paragraph") {
|
if (node.type.name === "paragraph") {
|
||||||
return 'Write anything. Enter "/" for commands';
|
return i18n.t('Write anything. Enter "/" for commands');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
@ -184,7 +185,7 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
})
|
}),
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|||||||
import { History } from "@tiptap/extension-history";
|
import { History } from "@tiptap/extension-history";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -35,6 +36,7 @@ export function TitleEditor({
|
|||||||
spaceSlug,
|
spaceSlug,
|
||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
||||||
const {
|
const {
|
||||||
@ -59,7 +61,7 @@ export function TitleEditor({
|
|||||||
}),
|
}),
|
||||||
Text,
|
Text,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: "Untitled",
|
placeholder: t("Untitled"),
|
||||||
showOnlyWhenEditable: false,
|
showOnlyWhenEditable: false,
|
||||||
}),
|
}),
|
||||||
History.configure({
|
History.configure({
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import React, { useState } from "react";
|
|||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
|
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function AddGroupMemberModal() {
|
export default function AddGroupMemberModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [userIds, setUserIds] = useState<string[]>([]);
|
const [userIds, setUserIds] = useState<string[]>([]);
|
||||||
@ -27,19 +29,19 @@ export default function AddGroupMemberModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={open}>Add group members</Button>
|
<Button onClick={open}>{t("Add group members")}</Button>
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title="Add group members">
|
<Modal opened={opened} onClose={close} title={t("Add group members")}>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
|
|
||||||
<MultiUserSelect
|
<MultiUserSelect
|
||||||
label={"Add group members"}
|
label={t("Add group members")}
|
||||||
onChange={handleMultiSelectChange}
|
onChange={handleMultiSelectChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button onClick={handleSubmit} type="submit">
|
<Button onClick={handleSubmit} type="submit">
|
||||||
Add
|
{t("Add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useForm, zodResolver } from "@mantine/form";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(50),
|
name: z.string().trim().min(2).max(50),
|
||||||
@ -14,6 +15,7 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function CreateGroupForm() {
|
export function CreateGroupForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const createGroupMutation = useCreateGroupMutation();
|
const createGroupMutation = useCreateGroupMutation();
|
||||||
const [userIds, setUserIds] = useState<string[]>([]);
|
const [userIds, setUserIds] = useState<string[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -52,16 +54,16 @@ export function CreateGroupForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
id="name"
|
id="name"
|
||||||
label="Group name"
|
label={t("Group name")}
|
||||||
placeholder="e.g Developers"
|
placeholder={t("e.g Developers")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
label="Group description"
|
label={t("Group description")}
|
||||||
placeholder="e.g Group for developers"
|
placeholder={t("e.g Group for developers")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
@ -70,13 +72,13 @@ export function CreateGroupForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MultiUserSelect
|
<MultiUserSelect
|
||||||
label={"Add group members"}
|
label={t("Add group members")}
|
||||||
onChange={handleMultiSelectChange}
|
onChange={handleMultiSelectChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit">{t("Create")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { Button, Divider, Modal } from "@mantine/core";
|
import { Button, Divider, Modal } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
|
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function CreateGroupModal() {
|
export default function CreateGroupModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={open}>Create group</Button>
|
<Button onClick={open}>{t("Create group")}</Button>
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title="Create group">
|
<Modal opened={opened} onClose={close} title={t("Create group")}>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
<CreateGroupForm />
|
<CreateGroupForm />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
@ -18,6 +19,7 @@ interface EditGroupFormProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const updateGroupMutation = useUpdateGroupMutation();
|
const updateGroupMutation = useUpdateGroupMutation();
|
||||||
const { isSuccess } = updateGroupMutation;
|
const { isSuccess } = updateGroupMutation;
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
@ -60,16 +62,16 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
|||||||
<TextInput
|
<TextInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
id="name"
|
id="name"
|
||||||
label="Group name"
|
label={t("Group name")}
|
||||||
placeholder="e.g Developers"
|
placeholder={t("e.g Developers")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
label="Group description"
|
label={t("Group description")}
|
||||||
placeholder="e.g Group for developers"
|
placeholder={t("e.g Group for developers")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
@ -79,7 +81,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit">{t("Save")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Divider, Modal } from "@mantine/core";
|
import { Divider, Modal } from "@mantine/core";
|
||||||
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
|
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface EditGroupModalProps {
|
interface EditGroupModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@ -10,9 +11,11 @@ export default function EditGroupModal({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}: EditGroupModalProps) {
|
}: EditGroupModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal opened={opened} onClose={onClose} title="Edit group">
|
<Modal opened={opened} onClose={onClose} title={t("Edit group")}>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
<EditGroupForm onClose={onClose} />
|
<EditGroupForm onClose={onClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import { IconDots, IconTrash } from "@tabler/icons-react";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function GroupActionMenu() {
|
export default function GroupActionMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { data: group, isLoading } = useGroupQuery(groupId);
|
const { data: group, isLoading } = useGroupQuery(groupId);
|
||||||
const deleteGroupMutation = useDeleteGroupMutation();
|
const deleteGroupMutation = useDeleteGroupMutation();
|
||||||
@ -24,15 +26,16 @@ export default function GroupActionMenu() {
|
|||||||
|
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Delete group",
|
title: t("Delete group"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to delete this group? Members will lose access
|
{t(
|
||||||
to resources this group has access to.
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: onDelete,
|
onConfirm: onDelete,
|
||||||
});
|
});
|
||||||
@ -57,7 +60,7 @@ export default function GroupActionMenu() {
|
|||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={open} disabled={group.isDefault}>
|
<Menu.Item onClick={open} disabled={group.isDefault}>
|
||||||
Edit group
|
{t("Edit group")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -66,7 +69,7 @@ export default function GroupActionMenu() {
|
|||||||
disabled={group.isDefault}
|
disabled={group.isDefault}
|
||||||
leftSection={<IconTrash size={16} stroke={2} />}
|
leftSection={<IconTrash size={16} stroke={2} />}
|
||||||
>
|
>
|
||||||
Delete group
|
{t("Delete group")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||||
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function GroupDetails() {
|
export default function GroupDetails() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
|
|||||||
@ -3,8 +3,12 @@ import {useGetGroupsQuery} from "@/features/group/queries/group-query";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatMemberCount } from "@/lib";
|
||||||
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetGroupsQuery();
|
const { data, isLoading } = useGetGroupsQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -14,13 +18,13 @@ export default function GroupList() {
|
|||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Group</Table.Th>
|
<Table.Th>{t("Group")}</Table.Th>
|
||||||
<Table.Th>Members</Table.Th>
|
<Table.Th>{t("Members")}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((group, index) => (
|
{data?.items.map((group: IGroup, index: number) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Anchor
|
<Anchor
|
||||||
@ -46,7 +50,6 @@ export default function GroupList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -54,12 +57,12 @@ export default function GroupList() {
|
|||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--mantine-color-text)",
|
color: "var(--mantine-color-text)",
|
||||||
whiteSpace: "nowrap"
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/settings/groups/${group.id}`}
|
to={`/settings/groups/${group.id}`}
|
||||||
>
|
>
|
||||||
{group.memberCount} members
|
{formatMemberCount(group.memberCount, t)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import {IconDots} from "@tabler/icons-react";
|
|||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
@ -26,15 +29,16 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
const openRemoveModal = (userId: string) =>
|
const openRemoveModal = (userId: string) =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Remove group member",
|
title: t("Remove group member"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to remove this user from the group? The user
|
{t(
|
||||||
will lose access to resources this group has access to.
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: {confirm: "Delete", cancel: "Cancel"},
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: () => onRemove(userId),
|
onConfirm: () => onRemove(userId),
|
||||||
});
|
});
|
||||||
@ -46,18 +50,21 @@ export default function GroupMembersList() {
|
|||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>User</Table.Th>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((user, index) => (
|
{data?.items.map((user: IUser, index: number) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
|
<CustomAvatar
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
name={user.name}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500}>
|
<Text fz="sm" fw={500}>
|
||||||
{user.name}
|
{user.name}
|
||||||
@ -68,11 +75,9 @@ export default function GroupMembersList() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light">Active</Badge>
|
<Badge variant="light">{t("Active")}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Menu
|
<Menu
|
||||||
@ -88,10 +93,9 @@ export default function GroupMembersList() {
|
|||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||||
Remove group member
|
{t("Remove group member")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
|
|||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query.ts";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query.ts";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import { IconUsersGroup } from "@tabler/icons-react";
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MultiGroupSelectProps {
|
interface MultiGroupSelectProps {
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
@ -29,6 +30,7 @@ export function MultiGroupSelect({
|
|||||||
description,
|
description,
|
||||||
mt,
|
mt,
|
||||||
}: MultiGroupSelectProps) {
|
}: MultiGroupSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
const { data: groups, isLoading } = useGetGroupsQuery({
|
const { data: groups, isLoading } = useGetGroupsQuery({
|
||||||
@ -66,8 +68,8 @@ export function MultiGroupSelect({
|
|||||||
hidePickedOptions
|
hidePickedOptions
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
description={description}
|
description={description}
|
||||||
label={label || "Add groups"}
|
label={label || t("Add groups")}
|
||||||
placeholder="Search for groups"
|
placeholder={t("Search for groups")}
|
||||||
mt={mt}
|
mt={mt}
|
||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
@ -75,7 +77,7 @@ export function MultiGroupSelect({
|
|||||||
clearable
|
clearable
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
nothingFoundMessage="No group found"
|
nothingFoundMessage={t("No group found")}
|
||||||
maxValues={50}
|
maxValues={50}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
|
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MultiUserSelectProps {
|
interface MultiUserSelectProps {
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
@ -29,6 +30,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
|
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
const { data: users, isLoading } = useWorkspaceMembersQuery({
|
const { data: users, isLoading } = useWorkspaceMembersQuery({
|
||||||
@ -65,15 +67,15 @@ export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
|
|||||||
renderOption={renderMultiSelectOption}
|
renderOption={renderMultiSelectOption}
|
||||||
hidePickedOptions
|
hidePickedOptions
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
label={label || "Add members"}
|
label={label || t("Add members")}
|
||||||
placeholder="Search for users"
|
placeholder={t("Search for users")}
|
||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
clearable
|
clearable
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
nothingFoundMessage="No user found"
|
nothingFoundMessage={t("No user found")}
|
||||||
maxValues={50}
|
maxValues={50}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { Text, Tabs, Space } from "@mantine/core";
|
import { Text, Tabs, Space } from "@mantine/core";
|
||||||
import { IconClockHour3 } from "@tabler/icons-react";
|
import { IconClockHour3 } from "@tabler/icons-react";
|
||||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
import RecentChanges from "@/components/common/recent-changes.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function HomeTabs() {
|
export default function HomeTabs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="recent">
|
<Tabs defaultValue="recent">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Recently updated
|
{t("Recently updated")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|||||||
@ -16,12 +16,14 @@ import {
|
|||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryList({ pageId }: Props) {
|
function HistoryList({ pageId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const {
|
const {
|
||||||
data: pageHistoryList,
|
data: pageHistoryList,
|
||||||
@ -36,14 +38,15 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
const confirmModal = () =>
|
const confirmModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Please confirm your action",
|
title: t("Please confirm your action"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to restore this version? Any changes not
|
{t(
|
||||||
versioned will be lost.
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
labels: { confirm: "Confirm", cancel: "Cancel" },
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
onConfirm: handleRestore,
|
onConfirm: handleRestore,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,7 +63,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
.setContent(activeHistoryData.content)
|
.setContent(activeHistoryData.content)
|
||||||
.run();
|
.run();
|
||||||
setHistoryModalOpen(false);
|
setHistoryModalOpen(false);
|
||||||
notifications.show({ message: "Successfully restored" });
|
notifications.show({ message: t("Successfully restored") });
|
||||||
}
|
}
|
||||||
}, [activeHistoryData]);
|
}, [activeHistoryData]);
|
||||||
|
|
||||||
@ -79,11 +82,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div>Error loading page history.</div>;
|
return <div>{t("Error loading page history.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pageHistoryList || pageHistoryList.items.length === 0) {
|
if (!pageHistoryList || pageHistoryList.items.length === 0) {
|
||||||
return <>No page history saved yet.</>;
|
return <>{t("No page history saved yet.")}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,14 +107,14 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
<Group p="xs" wrap="nowrap">
|
<Group p="xs" wrap="nowrap">
|
||||||
<Button size="compact-md" onClick={confirmModal}>
|
<Button size="compact-md" onClick={confirmModal}>
|
||||||
Restore
|
{t("Restore")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-md"
|
size="compact-md"
|
||||||
onClick={() => setHistoryModalOpen(false)}
|
onClick={() => setHistoryModalOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import { Modal, Text } from "@mantine/core";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
export default function HistoryModal({ pageId }: Props) {
|
export default function HistoryModal({ pageId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -21,7 +23,7 @@ export default function HistoryModal({ pageId }: Props) {
|
|||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
<Text size="md" fw={500}>
|
<Text size="md" fw={500}>
|
||||||
Page history
|
{t("Page history")}
|
||||||
</Text>
|
</Text>
|
||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
|
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
import { HistoryEditor } from '@/features/page-history/components/history-editor';
|
import { HistoryEditor } from "@/features/page-history/components/history-editor";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface HistoryProps {
|
interface HistoryProps {
|
||||||
historyId: string;
|
historyId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryView({ historyId }: HistoryProps) {
|
function HistoryView({ historyId }: HistoryProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -13,13 +15,15 @@ function HistoryView({ historyId }: HistoryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return <div>Error fetching page data.</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data &&
|
return (
|
||||||
|
data && (
|
||||||
<div>
|
<div>
|
||||||
<HistoryEditor content={data.content} title={data.title} />
|
<HistoryEditor content={data.content} title={data.title} />
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
|||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
@ -53,6 +54,7 @@ interface PageActionMenuProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
const { pageSlug, spaceSlug } = useParams();
|
||||||
@ -69,7 +71,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
|
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
|
||||||
|
|
||||||
clipboard.copy(pageUrl);
|
clipboard.copy(pageUrl);
|
||||||
notifications.show({ message: "Link copied" });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
@ -107,13 +109,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconLink size={16} />}
|
leftSection={<IconLink size={16} />}
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
>
|
>
|
||||||
Copy link
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<PageWidthToggle label="Full width" />
|
<PageWidthToggle label={t("Full width")} />
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
@ -121,7 +123,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconHistory size={16} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
>
|
>
|
||||||
Page history
|
{t("Page history")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@ -130,14 +132,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconFileExport size={16} />}
|
leftSection={<IconFileExport size={16} />}
|
||||||
onClick={openExportModal}
|
onClick={openExportModal}
|
||||||
>
|
>
|
||||||
Export
|
{t("Export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconPrinter size={16} />}
|
leftSection={<IconPrinter size={16} />}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
>
|
>
|
||||||
Print PDF
|
{t("Print PDF")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@ -148,7 +150,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={handleDeletePage}
|
onClick={handleDeletePage}
|
||||||
>
|
>
|
||||||
Delete
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface PageExportModalProps {
|
interface PageExportModalProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -16,6 +17,7 @@ export default function PageExportModal({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: PageExportModalProps) {
|
}: PageExportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@ -24,7 +26,7 @@ export default function PageExportModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Export failed:" + err.response?.data.message,
|
message: t("Export failed:") + err.response?.data.message,
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
@ -48,32 +50,29 @@ export default function PageExportModal({
|
|||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title fw={500}>Export page</Modal.Title>
|
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Format</Text>
|
<Text size="md">{t("Format")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" pt="md">
|
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Include subpages</Text>
|
<Text size="md">{t("Include subpages")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked />
|
<Switch defaultChecked />
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|
||||||
<Group justify="center" mt="md">
|
<Group justify="center" mt="md">
|
||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
Cancel
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport}>Export</Button>
|
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
@ -86,6 +85,8 @@ interface ExportFormatSelection {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={[
|
||||||
@ -98,7 +99,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
|||||||
comboboxProps={{ width: "120" }}
|
comboboxProps={{ width: "120" }}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
withCheckIcon={false}
|
withCheckIcon={false}
|
||||||
aria-label="Select export format"
|
aria-label={t("Select export format")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useAtom } from "jotai";
|
|||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface PageImportModalProps {
|
interface PageImportModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -24,6 +25,7 @@ export default function PageImportModal({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: PageImportModalProps) {
|
}: PageImportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal.Root
|
<Modal.Root
|
||||||
@ -38,7 +40,7 @@ export default function PageImportModal({
|
|||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title fw={500}>Import pages</Modal.Title>
|
<Modal.Title fw={500}>{t("Import pages")}</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
@ -55,6 +57,7 @@ interface ImportFormatSelection {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
@ -65,8 +68,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
const alert = notifications.show({
|
const alert = notifications.show({
|
||||||
title: "Importing pages",
|
title: t("Importing pages"),
|
||||||
message: "Page import is in progress. Please do not close this tab.",
|
message: t("Page import is in progress. Please do not close this tab."),
|
||||||
loading: true,
|
loading: true,
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
});
|
});
|
||||||
@ -92,13 +95,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
setTreeData(fullTree);
|
setTreeData(fullTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
|
const pageCountText =
|
||||||
|
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||||
|
|
||||||
notifications.update({
|
notifications.update({
|
||||||
id: alert,
|
id: alert,
|
||||||
color: "teal",
|
color: "teal",
|
||||||
title: `Successfully imported ${pageCountText}`,
|
title: `${t("Successfully imported")} ${pageCountText}`,
|
||||||
message: "Your import is complete.",
|
message: t("Your import is complete."),
|
||||||
icon: <IconCheck size={18} />,
|
icon: <IconCheck size={18} />,
|
||||||
loading: false,
|
loading: false,
|
||||||
autoClose: 5000,
|
autoClose: 5000,
|
||||||
@ -107,8 +111,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
notifications.update({
|
notifications.update({
|
||||||
id: alert,
|
id: alert,
|
||||||
color: "red",
|
color: "red",
|
||||||
title: `Failed to import pages`,
|
title: t("Failed to import pages"),
|
||||||
message: "Unable to import pages. Please try again.",
|
message: t("Unable to import pages. Please try again."),
|
||||||
icon: <IconX size={18} />,
|
icon: <IconX size={18} />,
|
||||||
loading: false,
|
loading: false,
|
||||||
autoClose: 5000,
|
autoClose: 5000,
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type UseDeleteModalProps = {
|
type UseDeleteModalProps = {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDeletePageModal() {
|
export function useDeletePageModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Are you sure you want to delete this page?",
|
title: t("Are you sure you want to delete this page?"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to delete this page? This will delete its
|
{t(
|
||||||
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.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm,
|
onConfirm,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { IPagination } 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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
@ -38,11 +39,12 @@ export function usePageQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCreatePageMutation() {
|
export function useCreatePageMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {},
|
onSuccess: (data) => {},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Failed to create page", color: "red" });
|
notifications.show({ message: t("Failed to create page"), color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -74,13 +76,14 @@ export function useUpdatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDeletePageMutation() {
|
export function useDeletePageMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId),
|
mutationFn: (pageId: string) => deletePage(pageId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notifications.show({ message: "Page deleted successfully" });
|
notifications.show({ message: t("Page deleted successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
@ -405,6 +406,7 @@ interface NodeMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||||
|
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();
|
||||||
@ -415,7 +417,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
const pageUrl =
|
const pageUrl =
|
||||||
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
|
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
|
||||||
clipboard.copy(pageUrl);
|
clipboard.copy(pageUrl);
|
||||||
notifications.show({ message: "Link copied" });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -446,7 +448,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
handleCopyLink();
|
handleCopyLink();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy link
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -457,7 +459,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
openExportModal();
|
openExportModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Export page
|
{t("Export page")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
@ -475,7 +477,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||||
@ -65,16 +67,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Spotlight.Search
|
<Spotlight.Search
|
||||||
placeholder="Search..."
|
placeholder={t("Search...")}
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
<Spotlight.ActionsList>
|
<Spotlight.ActionsList>
|
||||||
{query.length === 0 && pages.length === 0 && (
|
{query.length === 0 && pages.length === 0 && (
|
||||||
<Spotlight.Empty>Start typing to search...</Spotlight.Empty>
|
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{query.length > 0 && pages.length === 0 && (
|
{query.length > 0 && pages.length === 0 && (
|
||||||
<Spotlight.Empty>No results found...</Spotlight.Empty>
|
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pages.length > 0 && pages}
|
{pages.length > 0 && pages}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.
|
|||||||
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
|
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
|
||||||
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
|
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
|
||||||
import { SpaceRole } from "@/lib/types.ts";
|
import { SpaceRole } from "@/lib/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface AddSpaceMemberModalProps {
|
interface AddSpaceMemberModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -12,6 +13,7 @@ interface AddSpaceMemberModalProps {
|
|||||||
export default function AddSpaceMembersModal({
|
export default function AddSpaceMembersModal({
|
||||||
spaceId,
|
spaceId,
|
||||||
}: AddSpaceMemberModalProps) {
|
}: AddSpaceMemberModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [memberIds, setMemberIds] = useState<string[]>([]);
|
const [memberIds, setMemberIds] = useState<string[]>([]);
|
||||||
const [role, setRole] = useState<string>(SpaceRole.WRITER);
|
const [role, setRole] = useState<string>(SpaceRole.WRITER);
|
||||||
@ -48,8 +50,8 @@ export default function AddSpaceMembersModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={open}>Add space members</Button>
|
<Button onClick={open}>{t("Add space members")}</Button>
|
||||||
<Modal opened={opened} onClose={close} title="Add space members">
|
<Modal opened={opened} onClose={close} title={t("Add space members")}>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
@ -57,13 +59,13 @@ export default function AddSpaceMembersModal({
|
|||||||
<SpaceMemberRole
|
<SpaceMemberRole
|
||||||
onSelect={handleRoleSelection}
|
onSelect={handleRoleSelection}
|
||||||
defaultRole={role}
|
defaultRole={role}
|
||||||
label="Select role"
|
label={t("Select role")}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button onClick={handleSubmit} type="submit">
|
<Button onClick={handleSubmit} type="submit">
|
||||||
Add
|
{t("Add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||||
import { computeSpaceSlug } from "@/lib";
|
import { computeSpaceSlug } from "@/lib";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(50),
|
name: z.string().trim().min(2).max(50),
|
||||||
@ -23,6 +24,7 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function CreateSpaceForm() {
|
export function CreateSpaceForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const createSpaceMutation = useCreateSpaceMutation();
|
const createSpaceMutation = useCreateSpaceMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -74,8 +76,8 @@ export function CreateSpaceForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
id="name"
|
id="name"
|
||||||
label="Space name"
|
label={t("Space name")}
|
||||||
placeholder="e.g Product Team"
|
placeholder={t("e.g Product Team")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
@ -83,16 +85,16 @@ export function CreateSpaceForm() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
id="slug"
|
id="slug"
|
||||||
label="Space slug"
|
label={t("Space slug")}
|
||||||
placeholder="e.g product"
|
placeholder={t("e.g product")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("slug")}
|
{...form.getInputProps("slug")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
label="Space description"
|
label={t("Space description")}
|
||||||
placeholder="e.g Space for product team"
|
placeholder={t("e.g Space for product team")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
@ -102,7 +104,7 @@ export function CreateSpaceForm() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit">{t("Create")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { Button, Divider, Modal } from "@mantine/core";
|
import { Button, Divider, Modal } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
|
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function CreateSpaceModal() {
|
export default function CreateSpaceModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={open}>Create space</Button>
|
<Button onClick={open}>{t("Create space")}</Button>
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title="Create space">
|
<Modal opened={opened} onClose={close} title={t("Create space")}>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
<CreateSpaceForm />
|
<CreateSpaceForm />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useForm, zodResolver } from "@mantine/form";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
@ -24,6 +25,7 @@ interface EditSpaceFormProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -65,8 +67,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
label="Name"
|
label={t("Name")}
|
||||||
placeholder="e.g Sales"
|
placeholder={t("e.g Sales")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
@ -74,7 +76,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="slug"
|
id="slug"
|
||||||
label="Slug"
|
label={t("Slug")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
{...form.getInputProps("slug")}
|
{...form.getInputProps("slug")}
|
||||||
@ -82,8 +84,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
label="Description"
|
label={t("Description")}
|
||||||
placeholder="e.g Space for sales team to collaborate"
|
placeholder={t("e.g Space for sales team to collaborate")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
autosize
|
autosize
|
||||||
@ -96,7 +98,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit" disabled={!form.isDirty()}>
|
<Button type="submit" disabled={!form.isDirty()}>
|
||||||
Save
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useSearchSuggestionsQuery } from "@/features/search/queries/search-quer
|
|||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MultiMemberSelectProps {
|
interface MultiMemberSelectProps {
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
@ -30,6 +31,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
@ -83,14 +85,14 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
const updatedUserGroups = mergeItemsIntoGroups(
|
const updatedUserGroups = mergeItemsIntoGroups(
|
||||||
data,
|
data,
|
||||||
userItems,
|
userItems,
|
||||||
"Select a user",
|
t("Select a user"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge group items into groups
|
// Merge group items into groups
|
||||||
const finalData = mergeItemsIntoGroups(
|
const finalData = mergeItemsIntoGroups(
|
||||||
updatedUserGroups,
|
updatedUserGroups,
|
||||||
groupItems,
|
groupItems,
|
||||||
"Select a group",
|
t("Select a group"),
|
||||||
);
|
);
|
||||||
|
|
||||||
setData(finalData);
|
setData(finalData);
|
||||||
@ -103,8 +105,8 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
renderOption={renderMultiSelectOption}
|
renderOption={renderMultiSelectOption}
|
||||||
hidePickedOptions
|
hidePickedOptions
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
label="Add members"
|
label={t("Add members")}
|
||||||
placeholder="Search for users and groups"
|
placeholder={t("Search for users and groups")}
|
||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SpaceSettingsModalProps {
|
interface SpaceSettingsModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -21,6 +22,7 @@ export default function SpaceSettingsModal({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}: SpaceSettingsModalProps) {
|
}: SpaceSettingsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const spaceRules = space?.membership?.permissions;
|
||||||
@ -50,10 +52,10 @@ export default function SpaceSettingsModal({
|
|||||||
<Tabs defaultValue="members">
|
<Tabs defaultValue="members">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab fw={500} value="general">
|
<Tabs.Tab fw={500} value="general">
|
||||||
Settings
|
{t("Settings")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab fw={500} value="members">
|
<Tabs.Tab fw={500} value="members">
|
||||||
Members
|
{t("Members")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { Avatar, Group, Select, SelectProps, Text } from '@mantine/core';
|
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
|
||||||
import { useGetSpacesQuery } from '@/features/space/queries/space-query.ts';
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { ISpace } from '../../types/space.types';
|
import { ISpace } from "../../types/space.types";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SpaceSelectProps {
|
interface SpaceSelectProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@ -10,7 +11,7 @@ interface SpaceSelectProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
||||||
<div>
|
<div>
|
||||||
@ -20,7 +21,8 @@ const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const { t } = useTranslation();
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
const { data: spaces, isLoading } = useGetSpacesQuery({
|
const { data: spaces, isLoading } = useGetSpacesQuery({
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
@ -41,7 +43,7 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
|||||||
|
|
||||||
const filteredSpaceData = spaceData.filter(
|
const filteredSpaceData = spaceData.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
!data.find((existingUser) => existingUser.value === user.value)
|
!data.find((existingUser) => existingUser.value === user.value),
|
||||||
);
|
);
|
||||||
setData((prevData) => [...prevData, ...filteredSpaceData]);
|
setData((prevData) => [...prevData, ...filteredSpaceData]);
|
||||||
}
|
}
|
||||||
@ -53,14 +55,14 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
|||||||
renderOption={renderSelectOption}
|
renderOption={renderSelectOption}
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
//label={label || 'Select space'}
|
//label={label || 'Select space'}
|
||||||
placeholder="Search for spaces"
|
placeholder={t("Search for spaces")}
|
||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
clearable
|
clearable
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
nothingFoundMessage="No space found"
|
nothingFoundMessage={t("No space found")}
|
||||||
limit={50}
|
limit={50}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
comboboxProps={{ width: 300, withinPortal: false }}
|
comboboxProps={{ width: 300, withinPortal: false }}
|
||||||
|
|||||||
@ -35,10 +35,12 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { SwitchSpace } from "./switch-space";
|
import { SwitchSpace } from "./switch-space";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [opened, { open: openSettings, close: closeSettings }] =
|
const [opened, { open: openSettings, close: closeSettings }] =
|
||||||
@ -89,7 +91,7 @@ export function SpaceSidebar() {
|
|||||||
className={classes.menuItemIcon}
|
className={classes.menuItemIcon}
|
||||||
stroke={2}
|
stroke={2}
|
||||||
/>
|
/>
|
||||||
<span>Overview</span>
|
<span>{t("Overview")}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
@ -100,7 +102,7 @@ export function SpaceSidebar() {
|
|||||||
className={classes.menuItemIcon}
|
className={classes.menuItemIcon}
|
||||||
stroke={2}
|
stroke={2}
|
||||||
/>
|
/>
|
||||||
<span>Search</span>
|
<span>{t("Search")}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
@ -111,7 +113,7 @@ export function SpaceSidebar() {
|
|||||||
className={classes.menuItemIcon}
|
className={classes.menuItemIcon}
|
||||||
stroke={2}
|
stroke={2}
|
||||||
/>
|
/>
|
||||||
<span>Space settings</span>
|
<span>{t("Space settings")}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
@ -129,7 +131,7 @@ export function SpaceSidebar() {
|
|||||||
className={classes.menuItemIcon}
|
className={classes.menuItemIcon}
|
||||||
stroke={2}
|
stroke={2}
|
||||||
/>
|
/>
|
||||||
<span>New page</span>
|
<span>{t("New page")}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
@ -139,7 +141,7 @@ export function SpaceSidebar() {
|
|||||||
<div className={clsx(classes.section, classes.sectionPages)}>
|
<div className={clsx(classes.section, classes.sectionPages)}>
|
||||||
<Group className={classes.pagesHeader} justify="space-between">
|
<Group className={classes.pagesHeader} justify="space-between">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
Pages
|
{t("Pages")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{spaceAbility.can(
|
{spaceAbility.can(
|
||||||
@ -149,12 +151,12 @@ export function SpaceSidebar() {
|
|||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
||||||
|
|
||||||
<Tooltip label="Create page" withArrow position="right">
|
<Tooltip label={t("Create page")} withArrow position="right">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size={18}
|
size={18}
|
||||||
onClick={handleCreatePage}
|
onClick={handleCreatePage}
|
||||||
aria-label="Create page"
|
aria-label={t("Create page")}
|
||||||
>
|
>
|
||||||
<IconPlus />
|
<IconPlus />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -191,6 +193,7 @@ interface SpaceMenuProps {
|
|||||||
onSpaceSettings: () => void;
|
onSpaceSettings: () => void;
|
||||||
}
|
}
|
||||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
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 }] =
|
||||||
@ -201,11 +204,15 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
<Menu width={200} shadow="md" withArrow>
|
<Menu width={200} shadow="md" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Import pages & space settings"
|
label={t("Import pages & space settings")}
|
||||||
withArrow
|
withArrow
|
||||||
position="top"
|
position="top"
|
||||||
>
|
>
|
||||||
<ActionIcon variant="default" size={18} aria-label="Space menu">
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size={18}
|
||||||
|
aria-label={t("Space menu")}
|
||||||
|
>
|
||||||
<IconDots />
|
<IconDots />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -216,7 +223,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
onClick={openImportModal}
|
onClick={openImportModal}
|
||||||
leftSection={<IconArrowDown size={16} />}
|
leftSection={<IconArrowDown size={16} />}
|
||||||
>
|
>
|
||||||
Import pages
|
{t("Import pages")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -232,7 +239,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
onClick={onSpaceSettings}
|
onClick={onSpaceSettings}
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
>
|
>
|
||||||
Space settings
|
{t("Space settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -5,12 +5,14 @@ import { Button, Divider, Group, Text } from '@mantine/core';
|
|||||||
import DeleteSpaceModal from './delete-space-modal';
|
import DeleteSpaceModal from './delete-space-modal';
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import ExportModal from "@/components/common/export-modal.tsx";
|
import ExportModal from "@/components/common/export-modal.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SpaceDetailsProps {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
@ -20,7 +22,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
{space && (
|
{space && (
|
||||||
<div>
|
<div>
|
||||||
<Text my="md" fw={600}>
|
<Text my="md" fw={600}>
|
||||||
Details
|
{t("Details")}
|
||||||
</Text>
|
</Text>
|
||||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||||
|
|
||||||
@ -33,12 +35,12 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Text size="md">Export space</Text>
|
<Text size="md">Export space</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Export all pages and attachments in this space
|
{t("Export all pages and attachments in this space.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={openExportModal}>
|
<Button onClick={openExportModal}>
|
||||||
Export
|
{t("Export")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@ -46,9 +48,9 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Delete space</Text>
|
<Text size="md">{t("Delete space")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Delete this space with all its pages and data.
|
{t("Delete this space with all its pages and data.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
|||||||
import { Link } from "react-router-dom";
|
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";
|
||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery();
|
const { data, isLoading } = useGetSpacesQuery();
|
||||||
|
|
||||||
const cards = data?.items.map((space, index) => (
|
const cards = data?.items.map((space, index) => (
|
||||||
@ -33,7 +35,7 @@ export default function SpaceGrid() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text c="dimmed" size="xs" fw={700} mt="md">
|
<Text c="dimmed" size="xs" fw={700} mt="md">
|
||||||
{formatMemberCount(space.memberCount)}
|
{formatMemberCount(space.memberCount, t)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
));
|
));
|
||||||
@ -41,7 +43,7 @@ export default function SpaceGrid() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text fz="sm" fw={500} mb={"md"}>
|
<Text fz="sm" fw={500} mb={"md"}>
|
||||||
Spaces you belong to
|
{t("Spaces you belong to")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import { IconClockHour3 } from "@tabler/icons-react";
|
|||||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
import RecentChanges from "@/components/common/recent-changes.tsx";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SpaceHomeTabs() {
|
export default function SpaceHomeTabs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
@ -13,7 +15,7 @@ export default function SpaceHomeTabs() {
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Recently updated
|
{t("Recently updated")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
|
|||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery();
|
const { data, isLoading } = useGetSpacesQuery();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
@ -22,8 +24,8 @@ export default function SpaceList() {
|
|||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Space</Table.Th>
|
<Table.Th>{t("Space")}</Table.Th>
|
||||||
<Table.Th>Members</Table.Th>
|
<Table.Th>{t("Members")}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
@ -51,9 +53,10 @@ export default function SpaceList() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" style={{whiteSpace: 'nowrap'}}>{formatMemberCount(space.memberCount)}</Text>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatMemberCount(space.memberCount, t)}
|
||||||
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
spaceRoleData,
|
spaceRoleData,
|
||||||
} from "@/features/space/types/space-role-data.ts";
|
} from "@/features/space/types/space-role-data.ts";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@ -28,7 +29,9 @@ export default function SpaceMembersList({
|
|||||||
spaceId,
|
spaceId,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId);
|
const { data, isLoading } = useSpaceMembersQuery(spaceId);
|
||||||
|
|
||||||
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
||||||
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
||||||
|
|
||||||
@ -79,15 +82,16 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
const openRemoveModal = (memberId: string, type: MemberType) =>
|
const openRemoveModal = (memberId: string, type: MemberType) =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Remove space member",
|
title: t("Remove space member"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to remove this user from the space? The user
|
{t(
|
||||||
will lose all access to this space.
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: {confirm: "Remove", cancel: "Cancel"},
|
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: () => onRemove(memberId, type),
|
onConfirm: () => onRemove(memberId, type),
|
||||||
});
|
});
|
||||||
@ -99,8 +103,8 @@ export default function SpaceMembersList({
|
|||||||
<Table verticalSpacing={8}>
|
<Table verticalSpacing={8}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Member</Table.Th>
|
<Table.Th>{t("Member")}</Table.Th>
|
||||||
<Table.Th>Role</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@ -127,7 +131,7 @@ export default function SpaceMembersList({
|
|||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
{member.type == "group" &&
|
{member.type == "group" &&
|
||||||
`Group - ${formatMemberCount(member?.memberCount)}`}
|
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -171,7 +175,7 @@ export default function SpaceMembersList({
|
|||||||
openRemoveModal(member.id, member.type)
|
openRemoveModal(member.id, member.type)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Remove space member
|
{t("Remove space member")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const spaceRoleData: IRoleData[] = [
|
|||||||
{
|
{
|
||||||
label: "Can edit",
|
label: "Can edit",
|
||||||
value: SpaceRole.WRITER,
|
value: SpaceRole.WRITER,
|
||||||
description: "Can create and edit pages in space.",
|
description: "Can create and edit pages in space",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Can view",
|
label: "Can view",
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import { useAtom } from "jotai";
|
|||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { FileButton, Tooltip } from "@mantine/core";
|
import { FileButton, Tooltip } from "@mantine/core";
|
||||||
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||||
|
|
||||||
export default function AccountAvatar() {
|
export default function AccountAvatar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setUser] = useAtom(userAtom);
|
const [, setUser] = useAtom(userAtom);
|
||||||
@ -36,7 +38,7 @@ export default function AccountAvatar() {
|
|||||||
<>
|
<>
|
||||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip label="Change photo" position="bottom">
|
<Tooltip label={t("Change photo")} position="bottom">
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
{...props}
|
{...props}
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { Group, Text, Select } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { updateUser } from "../services/user-service";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "../atoms/current-user-atom";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function AccountLanguage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Language")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("Choose your preferred interface language.")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguageSwitcher() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
const [language, setLanguage] = useState(
|
||||||
|
user?.locale === "en" ? "en-US" : user.locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = async (value: string) => {
|
||||||
|
const updatedUser = await updateUser({ locale: value });
|
||||||
|
|
||||||
|
setLanguage(value);
|
||||||
|
setUser(updatedUser);
|
||||||
|
|
||||||
|
i18n.changeLanguage(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
label={t("Select language")}
|
||||||
|
data={[
|
||||||
|
{ value: "en-US", label: "English (United States)" },
|
||||||
|
{ value: "zh-CN", label: "中文 (简体)" },
|
||||||
|
]}
|
||||||
|
value={language}
|
||||||
|
onChange={handleChange}
|
||||||
|
allowDeselect={false}
|
||||||
|
checkIconPosition="right"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,9 +8,10 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TextInput, Button } from "@mantine/core";
|
import { TextInput, Button } from "@mantine/core";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(40).nonempty("Your name cannot be blank"),
|
name: z.string().min(2).max(40),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
@ -18,6 +19,7 @@ type FormValues = z.infer<typeof formSchema>;
|
|||||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||||
|
|
||||||
export default function AccountNameForm() {
|
export default function AccountNameForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setUser] = useAtom(userAtom);
|
const [, setUser] = useAtom(userAtom);
|
||||||
@ -36,12 +38,12 @@ export default function AccountNameForm() {
|
|||||||
const updatedUser = await updateUser(data);
|
const updatedUser = await updateUser(data);
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Updated successfully",
|
message: t("Updated successfully"),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Failed to update data",
|
message: t("Failed to update data"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -53,13 +55,13 @@ export default function AccountNameForm() {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
label="Name"
|
label={t("Name")}
|
||||||
placeholder="Your name"
|
placeholder={t("Your name")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
|
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
|
||||||
Save
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,14 +5,17 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
MantineColorScheme,
|
MantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function AccountTheme() {
|
export default function AccountTheme() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Theme</Text>
|
<Text size="md">{t("Theme")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Choose your preferred color scheme.
|
{t("Choose your preferred color scheme.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -22,6 +25,7 @@ export default function AccountTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ThemeSwitcher() {
|
function ThemeSwitcher() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const handleChange = (value: MantineColorScheme) => {
|
const handleChange = (value: MantineColorScheme) => {
|
||||||
@ -30,11 +34,11 @@ function ThemeSwitcher() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
label="Select theme"
|
label={t("Select theme")}
|
||||||
data={[
|
data={[
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: t("Light") },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: t("Dark") },
|
||||||
{ value: "auto", label: "System settings" },
|
{ value: "auto", label: t("System settings") },
|
||||||
]}
|
]}
|
||||||
value={colorScheme}
|
value={colorScheme}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|||||||
@ -13,15 +13,17 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ChangeEmail() {
|
export default function ChangeEmail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Email</Text>
|
<Text size="md">{t("Email")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{currentUser?.user.email}
|
{currentUser?.user.email}
|
||||||
</Text>
|
</Text>
|
||||||
@ -29,13 +31,15 @@ export default function ChangeEmail() {
|
|||||||
|
|
||||||
{/*
|
{/*
|
||||||
<Button onClick={open} variant="default">
|
<Button onClick={open} variant="default">
|
||||||
Change email
|
{t("Change email")}
|
||||||
</Button>
|
</Button>
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title="Change email" centered>
|
<Modal opened={opened} onClose={close} title={t("Change email")} centered>
|
||||||
<Text mb="md">
|
<Text mb="md">
|
||||||
To change your email, you have to enter your password and new email.
|
{t(
|
||||||
|
"To change your email, you have to enter your password and new email.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<ChangeEmailForm />
|
<ChangeEmailForm />
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -53,6 +57,7 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
function ChangeEmailForm() {
|
function ChangeEmailForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -71,8 +76,8 @@ function ChangeEmailForm() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
placeholder="Enter your password"
|
placeholder={t("Enter your password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mb="md"
|
mb="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
@ -80,16 +85,16 @@ function ChangeEmailForm() {
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
label="Email"
|
label={t("Email")}
|
||||||
description="Enter your new preferred email"
|
description={t("Enter your new preferred email")}
|
||||||
placeholder="New email"
|
placeholder={t("New email")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mb="md"
|
mb="md"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||||
Change email
|
{t("Change email")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,25 +6,34 @@ import * as React from "react";
|
|||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { changePassword } from "@/features/auth/services/auth-service.ts";
|
import { changePassword } from "@/features/auth/services/auth-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ChangePassword() {
|
export default function ChangePassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Password</Text>
|
<Text size="md">{t("Password")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
You can change your password here.
|
{t("You can change your password here.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={open} variant="default">
|
<Button onClick={open} variant="default">
|
||||||
Change password
|
{t("Change password")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title="Change password" centered>
|
<Modal
|
||||||
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={t("Change password")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Text mb="md">
|
||||||
|
{t("Your password must be a minimum of 8 characters.")}
|
||||||
|
</Text>
|
||||||
<ChangePasswordForm onClose={close} />
|
<ChangePasswordForm onClose={close} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Group>
|
</Group>
|
||||||
@ -44,6 +53,7 @@ interface ChangePasswordFormProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -62,7 +72,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
newPassword: data.newPassword,
|
newPassword: data.newPassword,
|
||||||
});
|
});
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Password changed successfully",
|
message: t("Password changed successfully"),
|
||||||
});
|
});
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
@ -78,9 +88,9 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Current password"
|
label={t("Current password")}
|
||||||
name="oldPassword"
|
name="oldPassword"
|
||||||
placeholder="Enter your current password"
|
placeholder={t("Enter your current password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mb="md"
|
mb="md"
|
||||||
data-autofocus
|
data-autofocus
|
||||||
@ -88,8 +98,8 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="New password"
|
label={t("New password")}
|
||||||
placeholder="Enter your new password"
|
placeholder={t("Enter your new password")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mb="md"
|
mb="md"
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
@ -97,7 +107,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||||
Change password
|
{t("Change password")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -3,14 +3,17 @@ import { useAtom } from "jotai/index";
|
|||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function PageWidthPref() {
|
export default function PageWidthPref() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">Full page width</Text>
|
<Text size="md">{t("Full page width")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Choose your preferred page width.
|
{t("Choose your preferred page width.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -24,6 +27,7 @@ interface PageWidthToggleProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
const [checked, setChecked] = useState(
|
const [checked, setChecked] = useState(
|
||||||
user.settings?.preferences?.fullPageWidth,
|
user.settings?.preferences?.fullPageWidth,
|
||||||
@ -43,7 +47,7 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
|||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
defaultChecked={checked}
|
defaultChecked={checked}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
aria-label="Toggle full page width"
|
aria-label={t("Toggle full page width")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface IUser {
|
|||||||
invitedById: string;
|
invitedById: string;
|
||||||
lastLoginAt: string;
|
lastLoginAt: string;
|
||||||
lastActiveAt: Date;
|
lastActiveAt: Date;
|
||||||
|
locale: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
role: string;
|
role: string;
|
||||||
|
|||||||
@ -2,14 +2,19 @@ import { useAtom } from "jotai";
|
|||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
const { data, isLoading, error } = useCurrentUser();
|
const { data, isLoading, error } = useCurrentUser();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data.user && data.workspace) {
|
if (data && data.user && data.workspace) {
|
||||||
setCurrentUser(data);
|
setCurrentUser(data);
|
||||||
|
i18n.changeLanguage(
|
||||||
|
data.user.locale === "en" ? "en-US" : data.user.locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [data, isLoading]);
|
}, [data, isLoading]);
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import {
|
|||||||
useResendInvitationMutation,
|
useResendInvitationMutation,
|
||||||
useRevokeInvitationMutation,
|
useRevokeInvitationMutation,
|
||||||
} from "@/features/workspace/queries/workspace-query.ts";
|
} from "@/features/workspace/queries/workspace-query.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
invitationId: string;
|
invitationId: string;
|
||||||
}
|
}
|
||||||
export default function InviteActionMenu({ invitationId }: Props) {
|
export default function InviteActionMenu({ invitationId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const resendInvitationMutation = useResendInvitationMutation();
|
const resendInvitationMutation = useResendInvitationMutation();
|
||||||
const revokeInvitationMutation = useRevokeInvitationMutation();
|
const revokeInvitationMutation = useRevokeInvitationMutation();
|
||||||
|
|
||||||
@ -24,15 +26,16 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
|||||||
|
|
||||||
const openRevokeModal = () =>
|
const openRevokeModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Revoke invitation",
|
title: t("Revoke invitation"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Are you sure you want to revoke this invitation? The user will not be
|
{t(
|
||||||
able to join the workspace.
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: "Revoke", cancel: "Don't" },
|
labels: { confirm: t("Revoke"), cancel: t("Don't") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: onRevoke,
|
onConfirm: onRevoke,
|
||||||
});
|
});
|
||||||
@ -54,14 +57,14 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={onResend}>Resend invitation</Menu.Item>
|
<Menu.Item onClick={onResend}>{t("Resend invitation")}</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
onClick={openRevokeModal}
|
onClick={openRevokeModal}
|
||||||
leftSection={<IconTrash size={16} stroke={2} />}
|
leftSection={<IconTrash size={16} stroke={2} />}
|
||||||
>
|
>
|
||||||
Revoke invitation
|
{t("Revoke invitation")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { UserRole } from "@/lib/types.ts";
|
|||||||
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
|
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
|
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
export function WorkspaceInviteForm({ onClose }: Props) {
|
export function WorkspaceInviteForm({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [emails, setEmails] = useState<string[]>([]);
|
const [emails, setEmails] = useState<string[]>([]);
|
||||||
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
|
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
|
||||||
const [groupIds, setGroupIds] = useState<string[]>([]);
|
const [groupIds, setGroupIds] = useState<string[]>([]);
|
||||||
@ -44,9 +46,11 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
|||||||
|
|
||||||
<TagsInput
|
<TagsInput
|
||||||
mt="sm"
|
mt="sm"
|
||||||
description="Enter valid email addresses separated by comma or space [max: 50]"
|
description={t(
|
||||||
label="Invite by email"
|
"Enter valid email addresses separated by comma or space max_50",
|
||||||
placeholder="enter valid emails addresses"
|
)}
|
||||||
|
label={t("Invite by email")}
|
||||||
|
placeholder={t("enter valid emails addresses")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
splitChars={[",", " "]}
|
splitChars={[",", " "]}
|
||||||
maxDropdownHeight={200}
|
maxDropdownHeight={200}
|
||||||
@ -56,11 +60,17 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
mt="sm"
|
mt="sm"
|
||||||
description="Select role to assign to all invited members"
|
description={t("Select role to assign to all invited members")}
|
||||||
label="Select role"
|
label={t("Select role")}
|
||||||
placeholder="Choose a role"
|
placeholder={t("Choose a role")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
data={userRoleData.filter((role) => role.value !== UserRole.OWNER)}
|
data={userRoleData
|
||||||
|
.filter((role) => role.value !== UserRole.OWNER)
|
||||||
|
.map((role) => ({
|
||||||
|
...role,
|
||||||
|
label: t(`${role.label}`),
|
||||||
|
description: t(`${role.description}`),
|
||||||
|
}))}
|
||||||
defaultValue={UserRole.MEMBER}
|
defaultValue={UserRole.MEMBER}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
@ -69,8 +79,10 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
|||||||
|
|
||||||
<MultiGroupSelect
|
<MultiGroupSelect
|
||||||
mt="sm"
|
mt="sm"
|
||||||
description="Invited members will be granted access to spaces the groups can access"
|
description={t(
|
||||||
label={"Add to groups"}
|
"Invited members will be granted access to spaces the groups can access",
|
||||||
|
)}
|
||||||
|
label={t("Add to groups")}
|
||||||
onChange={handleGroupSelect}
|
onChange={handleGroupSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -79,7 +91,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={createInvitationMutation.isPending}
|
loading={createInvitationMutation.isPending}
|
||||||
>
|
>
|
||||||
Send invitation
|
{t("Send invitation")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
|
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
|
||||||
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
|
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceInviteModal() {
|
export default function WorkspaceInviteModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={open}>Invite members</Button>
|
<Button onClick={open}>{t("Invite members")}</Button>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
size="550"
|
size="550"
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
title="Invite new members"
|
title={t("Invite new members")}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { useAtom } from "jotai";
|
|||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceInviteSection() {
|
export default function WorkspaceInviteSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [inviteLink, setInviteLink] = useState<string>("");
|
const [inviteLink, setInviteLink] = useState<string>("");
|
||||||
|
|
||||||
@ -17,10 +19,10 @@ export default function WorkspaceInviteSection() {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Text fw={500} mb="sm">
|
<Text fw={500} mb="sm">
|
||||||
Invite link
|
{t("Invite link")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" mb="sm">
|
<Text c="dimmed" mb="sm">
|
||||||
Anyone with this link can join this workspace.
|
{t("Anyone with this link can join this workspace.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -31,7 +33,7 @@ export default function WorkspaceInviteSection() {
|
|||||||
<CopyButton value={inviteLink}>
|
<CopyButton value={inviteLink}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
<Button color={copied ? "teal" : ""} onClick={copy}>
|
<Button color={copied ? "teal" : ""} onClick={copy}>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? t("Copied") : t("Copy")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CopyButton>
|
</CopyButton>
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import InviteActionMenu from "@/features/workspace/components/members/components
|
|||||||
import {IconInfoCircle} from "@tabler/icons-react";
|
import {IconInfoCircle} from "@tabler/icons-react";
|
||||||
import {formattedDate, timeAgo} from "@/lib/time.ts";
|
import {formattedDate, timeAgo} from "@/lib/time.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
@ -16,7 +18,9 @@ export default function WorkspaceInvitesTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
|
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
|
||||||
Invited members who are yet to accept their invitation will appear here.
|
{t(
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.",
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
@ -25,9 +29,9 @@ export default function WorkspaceInvitesTable() {
|
|||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Email</Table.Th>
|
<Table.Th>{t("Email")}</Table.Th>
|
||||||
<Table.Th>Role</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th>Date</Table.Th>
|
<Table.Th>{t("Date")}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
@ -45,7 +49,7 @@ export default function WorkspaceInvitesTable() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
|
<Table.Td>{t(getUserRoleLabel(invitation.role))}</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
|
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
|
||||||
|
|
||||||
|
|||||||
@ -12,13 +12,17 @@ import {
|
|||||||
} from "@/features/workspace/types/user-role-data.ts";
|
} from "@/features/workspace/types/user-role-data.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { UserRole } from "@/lib/types.ts";
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
||||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||||
const {isAdmin, isOwner} = useUserRole();
|
const {isAdmin, isOwner} = useUserRole();
|
||||||
|
|
||||||
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
|
const assignableUserRoles = isOwner
|
||||||
|
? userRoleData
|
||||||
|
: userRoleData.filter((role) => role.value !== UserRole.OWNER);
|
||||||
|
|
||||||
const handleRoleChange = async (
|
const handleRoleChange = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -44,9 +48,9 @@ export default function WorkspaceMembersTable() {
|
|||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>User</Table.Th>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th>Role</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
@ -66,11 +70,9 @@ export default function WorkspaceMembersTable() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light">Active</Badge>
|
<Badge variant="light">{t("Active")}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<RoleSelectMenu
|
<RoleSelectMenu
|
||||||
roles={assignableUserRoles}
|
roles={assignableUserRoles}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user