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:
lleohao 2025-01-04 21:17:17 +08:00 committed by GitHub
parent 290b7d9d94
commit 670ee64179
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
119 changed files with 1672 additions and 649 deletions

View File

@ -29,6 +29,8 @@
"date-fns": "^4.1.0",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.10.3",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
@ -43,6 +45,7 @@
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",

View 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"
}

View 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个字符。"
}

View File

@ -26,8 +26,10 @@ import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import { useTranslation } from "react-i18next";
export default function App() {
const { t } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
const authToken = useAtomValue(authTokensAtom);
@ -78,7 +80,7 @@ export default function App() {
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>Failed to load page. An error occurred.</>}
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>

View File

@ -12,6 +12,7 @@ import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
interface ExportModalProps {
id: string;
@ -29,6 +30,7 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const { t } = useTranslation();
const handleExport = async () => {
try {
@ -73,7 +75,7 @@ export default function ExportModal({
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
@ -84,7 +86,7 @@ export default function ExportModal({
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include subpages</Text>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch
onChange={(event) =>
@ -102,7 +104,7 @@ export default function ExportModal({
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include attachments</Text>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
@ -116,9 +118,9 @@ export default function ExportModal({
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
{t("Cancel")}
</Button>
<Button onClick={handleExport}>Export</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
@ -131,6 +133,8 @@ interface ExportFormatSelection {
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
return (
<Select
data={[
@ -143,7 +147,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
aria-label={t("Select export format")}
/>
);
}

View File

@ -8,17 +8,19 @@ import {
} from '@mantine/core';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import {buildPageUrl} from '@/features/page/page.utils.ts';
import {formattedDate} from '@/lib/time.ts';
import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
import {IconFileDescription} from '@tabler/icons-react';
import {getSpaceUrl} from '@/lib/config.ts';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import { useTranslation } from "react-i18next";
interface Props {
spaceId?: string;
}
export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation();
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
@ -26,7 +28,7 @@ export default function RecentChanges({spaceId}: Props) {
}
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 ? (
@ -48,7 +50,7 @@ export default function RecentChanges({spaceId}: Props) {
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || 'Untitled'}
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
@ -78,7 +80,7 @@ export default function RecentChanges({spaceId}: Props) {
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
No pages yet
{t("No pages yet")}
</Text>
);
}

View File

@ -11,10 +11,12 @@ import {
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
export function AppHeader() {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
@ -25,7 +27,7 @@ export function AppHeader() {
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{link.label}
{t(link.label)}
</Link>
));
@ -35,10 +37,10 @@ export function AppHeader() {
<Group wrap="nowrap">
{!isHomeRoute && (
<>
<Tooltip label="Sidebar toggle">
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label="Sidebar toggle"
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
@ -46,9 +48,9 @@ export function AppHeader() {
/>
</Tooltip>
<Tooltip label="Sidebar toggle">
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label="Sidebar toggle"
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"

View File

@ -3,9 +3,11 @@ import CommentList from "@/features/comment/components/comment-list.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
let title: string;
let component: ReactNode;
@ -25,7 +27,7 @@ export default function Aside() {
{component && (
<>
<Text mb="md" fw={500}>
{title}
{t(title)}
</Text>
<ScrollArea

View File

@ -13,8 +13,10 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
export default function TopMenu() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
@ -44,14 +46,14 @@ export default function TopMenu() {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Workspace</Menu.Label>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
Workspace settings
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
@ -59,12 +61,12 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
Manage members
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>Account</Menu.Label>
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
@ -88,7 +90,7 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
My profile
{t("My profile")}
</Menu.Item>
<Menu.Item
@ -96,13 +98,13 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
My preferences
{t("My preferences")}
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
Logout
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -11,6 +11,7 @@ import {
} from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
interface DataItem {
label: string;
@ -51,6 +52,7 @@ const groupedData: DataGroup[] = [
];
export default function SettingsSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
@ -62,7 +64,7 @@ export default function SettingsSidebar() {
const menuItems = groupedData.map((group) => (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{group.heading}
{t(group.heading)}
</Text>
{group.items.map((item) => (
<Link
@ -72,7 +74,7 @@ export default function SettingsSidebar() {
to={item.path}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{item.label}</span>
<span>{t(item.label)}</span>
</Link>
))}
</div>
@ -89,7 +91,7 @@ export default function SettingsSidebar() {
>
<IconArrowLeft stroke={2} />
</ActionIcon>
<Text fw={500}>Settings</Text>
<Text fw={500}>{t("Settings")}</Text>
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>

View File

@ -2,21 +2,24 @@ import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>404 page not found - Docmost</title>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<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}>
Sorry, we can't find the page you are looking for.
{t("Sorry, we can't find the page you are looking for.")}
</Text>
<Group justify="center">
<Button component={Link} to={"/home"} variant="subtle" size="md">
Take me back to homepage
{t("Take me back to homepage")}
</Button>
</Group>
</Container>

View File

@ -2,6 +2,7 @@ import React, { forwardRef } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Group, Text, Menu, Button } from "@mantine/core";
import { IRoleData } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
name: string;
@ -36,10 +37,12 @@ export default function RoleSelectMenu({
onChange,
disabled,
}: RoleMenuProps) {
const { t } = useTranslation();
return (
<Menu withArrow>
<Menu.Target>
<RoleButton name={roleName} disabled={disabled} />
<RoleButton name={t(roleName)} disabled={disabled} />
</Menu.Target>
<Menu.Dropdown>
@ -50,9 +53,9 @@ export default function RoleSelectMenu({
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{item.label}</Text>
<Text size="sm">{t(item.label)}</Text>
<Text size="xs" opacity={0.65}>
{item.description}
{t(item.description)}
</Text>
</div>
{item.label === roleName && <IconCheck size={20} />}

View File

@ -6,6 +6,7 @@ import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
@ -15,6 +16,7 @@ const formSchema = z.object({
});
export function ForgotPasswordForm() {
const { t } = useTranslation();
const { forgotPassword, isLoading } = useAuth();
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
@ -36,7 +38,7 @@ export function ForgotPasswordForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Forgot password
{t("Forgot password")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
@ -53,14 +55,15 @@ export function ForgotPasswordForm() {
{isTokenSent && (
<Text>
A password reset link has been sent to your email. Please check
your inbox.
{t(
"A password reset link has been sent to your email. Please check your inbox.",
)}
</Text>
)}
{!isTokenSent && (
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Send reset link
{t("Send reset link")}
</Button>
)}
</form>

View File

@ -17,6 +17,7 @@ import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -26,6 +27,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function InviteSignUpForm() {
const { t } = useTranslation();
const params = useParams();
const [searchParams] = useSearchParams();
@ -55,7 +57,7 @@ export function InviteSignUpForm() {
}
if (isError) {
return <div>invalid invitation link</div>;
return <div>{t("invalid invitation link")}</div>;
}
if (!invitation) {
@ -66,7 +68,7 @@ export function InviteSignUpForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Join the workspace
{t("Join the workspace")}
</Title>
<Stack align="stretch" justify="center" gap="xl">
@ -74,8 +76,8 @@ export function InviteSignUpForm() {
<TextInput
id="name"
type="text"
label="Name"
placeholder="enter your full name"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
@ -83,7 +85,7 @@ export function InviteSignUpForm() {
<TextInput
id="email"
type="email"
label="Email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
@ -91,14 +93,14 @@ export function InviteSignUpForm() {
/>
<PasswordInput
label="Password"
placeholder="Your password"
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign Up
{t("Sign Up")}
</Button>
</form>
</Stack>

View File

@ -9,13 +9,13 @@ import {
Button,
PasswordInput,
Box,
Anchor,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
@ -26,6 +26,7 @@ const formSchema = z.object({
});
export function LoginForm() {
const { t } = useTranslation();
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
@ -45,29 +46,29 @@ export function LoginForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Login
{t("Login")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label="Email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In
{t("Sign In")}
</Button>
</form>
@ -77,7 +78,7 @@ export function LoginForm() {
underline="never"
size="sm"
>
Forgot your password?
{t("Forgot your password?")}
</Anchor>
</Box>
</Container>

View File

@ -12,6 +12,7 @@ import {
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
newPassword: z
@ -24,6 +25,7 @@ interface PasswordResetFormProps {
}
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { t } = useTranslation();
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
@ -37,28 +39,28 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
async function onSubmit(data: IPasswordReset) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword
})
newPassword: data.newPassword,
});
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Password reset
{t("Password reset")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<PasswordInput
label="New password"
placeholder="Your new password"
label={t("New password")}
placeholder={t("Your new password")}
variant="filled"
mt="md"
{...form.getInputProps("newPassword")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Set password
{t("Set password")}
</Button>
</form>
</Box>

View File

@ -13,6 +13,7 @@ import {
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
@ -25,6 +26,7 @@ const formSchema = z.object({
});
export function SetupWorkspaceForm() {
const { t } = useTranslation();
const { setupWorkspace, isLoading } = useAuth();
// useRedirectIfAuthenticated();
@ -46,15 +48,15 @@ export function SetupWorkspaceForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Create workspace
{t("Create workspace")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label="Workspace Name"
placeholder="e.g ACME Inc"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
@ -63,8 +65,8 @@ export function SetupWorkspaceForm() {
<TextInput
id="name"
type="text"
label="Your Name"
placeholder="enter your full name"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
@ -73,7 +75,7 @@ export function SetupWorkspaceForm() {
<TextInput
id="email"
type="email"
label="Your Email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
@ -81,14 +83,14 @@ export function SetupWorkspaceForm() {
/>
<PasswordInput
label="Password"
placeholder="Enter a strong password"
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Setup workspace
{t("Setup workspace")}
</Button>
</form>
</Box>

View File

@ -1,4 +1,5 @@
import { Button, Group } from '@mantine/core';
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
type CommentActionsProps = {
onSave: () => void;
@ -6,9 +7,13 @@ type CommentActionsProps = {
};
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
const { t } = useTranslation();
return (
<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>
);
}

View File

@ -14,6 +14,7 @@ import { useCreateCommentMutation } from "@/features/comment/queries/comment-que
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
@ -21,6 +22,7 @@ interface CommentDialogProps {
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
@ -107,7 +109,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
<CommentEditor
onUpdate={handleCommentEditorChange}
placeholder="Write a comment"
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>

View File

@ -7,6 +7,7 @@ import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
interface CommentEditorProps {
defaultContent?: any;
@ -27,6 +28,7 @@ const CommentEditor = forwardRef(
}: CommentEditorProps,
ref,
) => {
const { t } = useTranslation();
const { ref: focusRef, focused } = useFocusWithin();
const commentEditor = useEditor({
@ -36,7 +38,7 @@ const CommentEditor = forwardRef(
dropcursor: false,
}),
Placeholder.configure({
placeholder: placeholder || "Reply...",
placeholder: placeholder || t("Reply..."),
}),
Underline,
Link,

View File

@ -24,7 +24,6 @@ function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation();

View File

@ -6,7 +6,6 @@ import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
@ -14,8 +13,10 @@ import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
function CommentList() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
@ -79,11 +80,11 @@ function CommentList() {
}
if (isError) {
return <div>Error loading comments.</div>;
return <div>{t("Error loading comments.")}</div>;
}
if (!comments || comments.items.length === 0) {
return <>No comments yet.</>;
return <>{t("No comments yet.")}</>;
}
return (

View File

@ -1,6 +1,7 @@
import { ActionIcon, Menu } from '@mantine/core';
import { IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { ActionIcon, Menu } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
type CommentMenuProps = {
onEditComment: () => void;
@ -8,34 +9,35 @@ type CommentMenuProps = {
};
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
const { t } = useTranslation();
//@ts-ignore
const openDeleteModal = () =>
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,
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onDeleteComment,
});
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="default" style={{ border: 'none' }}>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onEditComment}
leftSection={<IconEdit size={14} />}>
Edit comment
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
<Menu.Item leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
>
Delete comment
{t("Delete comment")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -1,34 +1,44 @@
import { ActionIcon } from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { useResolveCommentMutation } from '@/features/comment/queries/comment-query';
import { ActionIcon } from "@mantine/core";
import { IconCircleCheck } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
function ResolveComment({ commentId, pageId, resolvedAt }) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? 'green' : 'gray';
const iconColor = isResolved ? "green" : "gray";
//@ts-ignore
const 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,
labels: { confirm: 'Confirm', cancel: 'Cancel' },
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleResolveToggle,
});
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
await resolveCommentMutation.mutateAsync({
commentId,
resolved: !isResolved,
});
//TODO: remove comment mark
// Remove comment thread from state on resolve
} catch (error) {
console.error('Failed to toggle resolved state:', error);
console.error("Failed to toggle resolved state:", error);
}
};
return (
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
<ActionIcon
onClick={openConfirmModal}
variant="default"
style={{ border: "none" }}
>
<IconCircleCheck size={20} stroke={2} color={iconColor} />
</ActionIcon>
);

View File

@ -18,6 +18,7 @@ import {
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
@ -33,6 +34,7 @@ export function useCommentsQuery(
export function useCreateCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
@ -45,28 +47,37 @@ export function useCreateCommentMutation() {
//}
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: "Comment created successfully" });
notifications.show({ message: t("Comment created successfully") });
},
onError: (error) => {
notifications.show({ message: "Error creating comment", color: "red" });
notifications.show({
message: t("Error creating comment"),
color: "red",
});
},
});
}
export function useUpdateCommentMutation() {
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => updateComment(data),
onSuccess: (data) => {
notifications.show({ message: "Comment updated successfully" });
notifications.show({ message: t("Comment updated successfully") });
},
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) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
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) => {
notifications.show({ message: "Failed to delete comment", color: "red" });
notifications.show({
message: t("Failed to delete comment"),
color: "red",
});
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
@ -114,11 +129,11 @@ export function useResolveCommentMutation() {
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}*/
notifications.show({ message: "Comment resolved successfully" });
notifications.show({ message: t("Comment resolved successfully") });
},
onError: (error) => {
notifications.show({
message: "Failed to resolve comment",
message: t("Failed to resolve comment"),
color: "red",
});
},

View File

@ -1,8 +1,9 @@
import { handleAttachmentUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadAttachmentAction = handleAttachmentUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -23,7 +24,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}

View File

@ -26,6 +26,7 @@ import { useAtom } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
export interface BubbleMenuItem {
name: string;
@ -39,6 +40,7 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation();
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
@ -49,31 +51,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
name: "Bold",
isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold,
},
{
name: "italic",
name: "Italic",
isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic,
},
{
name: "underline",
name: "Underline",
isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline,
},
{
name: "strike",
name: "Strike",
isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough,
},
{
name: "code",
name: "Code",
isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
@ -81,7 +83,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
];
const commentItem: BubbleMenuItem = {
name: "comment",
name: "Comment",
isActive: () => props.editor.isActive("comment"),
command: () => {
const commentId = uuid7();
@ -138,13 +140,13 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={item.name} withArrow>
<Tooltip key={index} label={t(item.name)} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={item.name}
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
@ -175,7 +177,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
variant="default"
size="lg"
radius="0"
aria-label={commentItem.name}
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>

View File

@ -10,6 +10,7 @@ import {
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem {
name: string;
@ -106,6 +107,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
);
@ -117,7 +119,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return (
<Popover width={200} opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label="Text color" withArrow>
<Tooltip label={t("Text color")} withArrow>
<ActionIcon
variant="default"
size="lg"
@ -136,8 +138,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400">
<Text span c="dimmed" inherit>
COLOR
<Text span c="dimmed" tt="uppercase" inherit>
{t("Color")}
</Text>
<Button.Group orientation="vertical">
@ -155,7 +157,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
name !== t("Default") &&
editor
.chain()
.focus()
@ -165,7 +167,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
}}
style={{ border: "none" }}
>
{name}
{t(name)}
</Button>
))}
</Button.Group>

View File

@ -3,6 +3,7 @@ import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
interface LinkSelectorProps {
editor: ReturnType<typeof useEditor>;
@ -15,6 +16,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
@ -32,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label="Add link" withArrow>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"

View File

@ -14,6 +14,7 @@ import {
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>;
@ -33,6 +34,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const items: BubbleMenuItem[] = [
{
name: "Text",
@ -114,7 +117,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{activeItem?.name}
{t(activeItem?.name)}
</Button>
</Popover.Target>
@ -137,7 +140,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}}
style={{ border: "none" }}
>
{item.name}
{t(item.name)}
</Button>
))}
</Button.Group>

View File

@ -17,8 +17,10 @@ import {
IconInfoCircleFilled,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -71,11 +73,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Info">
<Tooltip position="top" label={t("Info")}>
<ActionIcon
onClick={() => setCalloutType("info")}
size="lg"
aria-label="Info"
aria-label={t("Info")}
variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
@ -84,11 +86,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Success">
<Tooltip position="top" label={t("Success")}>
<ActionIcon
onClick={() => setCalloutType("success")}
size="lg"
aria-label="Success"
aria-label={t("Success")}
variant={
editor.isActive("callout", { type: "success" })
? "light"
@ -99,11 +101,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Warning">
<Tooltip position="top" label={t("Warning")}>
<ActionIcon
onClick={() => setCalloutType("warning")}
size="lg"
aria-label="Warning"
aria-label={t("Warning")}
variant={
editor.isActive("callout", { type: "warning" })
? "light"
@ -114,11 +116,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Danger">
<Tooltip position="top" label={t("Danger")}>
<ActionIcon
onClick={() => setCalloutType("danger")}
size="lg"
aria-label="Danger"
aria-label={t("Danger")}
variant={
editor.isActive("callout", { type: "danger" })
? "light"

View File

@ -2,16 +2,17 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
import { useEffect, useState } from 'react';
import { IconCheck, IconCopy } from '@tabler/icons-react';
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
import classes from './code-block.module.css';
import React from 'react';
import { Suspense } from 'react';
import { useTranslation } from "react-i18next";
const MermaidView = React.lazy(
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
);
export default function CodeBlockView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, extension, editor, getPos } = props;
const { language } = node.attrs;
const [languageValue, setLanguageValue] = useState<string | null>(
@ -61,7 +62,7 @@ export default function CodeBlockView(props: NodeViewProps) {
<CopyButton value={node?.textContent} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? 'Copied' : 'Copy'}
label={copied ? t('Copied') : t('Copy')}
withArrow
position="right"
>

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
import { t } from "i18next";
mermaid.initialize({
startOnLoad: false,
@ -29,11 +30,11 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => {
if (props.editor.isEditable) {
setPreview(
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
);
} else {
setPreview(
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
`<div class="${classes.error}">${t("Invalid Mermaid diagram")}</div>`,
);
}
});

View File

@ -1,25 +1,34 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, Card, Image, 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 { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Card,
Image,
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 {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from 'react-drawio';
import { IAttachment } from '@/lib/types';
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>('');
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
@ -32,15 +41,15 @@ export default function DrawioView(props: NodeViewProps) {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || '') as string;
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
}
@ -54,7 +63,7 @@ export default function DrawioView(props: NodeViewProps) {
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = 'diagram.drawio.svg';
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
@ -81,15 +90,15 @@ export default function DrawioView(props: NodeViewProps) {
<NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
<div style={{ height: '100vh' }}>
<div style={{ height: "100vh" }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
libraries: true,
saveAndExit: true,
@ -97,7 +106,7 @@ export default function DrawioView(props: NodeViewProps) {
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== 'save') {
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
@ -116,7 +125,7 @@ export default function DrawioView(props: NodeViewProps) {
</Modal.Root>
{src ? (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
@ -125,8 +134,8 @@ export default function DrawioView(props: NodeViewProps) {
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
@ -137,7 +146,7 @@ export default function DrawioView(props: NodeViewProps) {
color="gray"
mx="xs"
style={{
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
}}
@ -152,20 +161,20 @@ export default function DrawioView(props: NodeViewProps) {
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
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">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit drawio diagram
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
</Card>

View File

@ -1,22 +1,37 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
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 { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
getEmbedProviderById,
getEmbedUrlAndProvider
getEmbedUrlAndProvider,
} 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({
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) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
@ -41,9 +56,9 @@ export default function EmbedView(props: NodeViewProps) {
updateAttributes({ src: data.url });
} else {
notifications.show({
message: `Invalid ${provider} embed link`,
position: 'top-right',
color: 'red'
message: t("Invalid {{provider}} embed link", { provider: provider }),
position: "top-right",
color: "red",
});
}
}
@ -62,7 +77,6 @@ export default function EmbedView(props: NodeViewProps) {
frameBorder="0"
></iframe>
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
@ -71,20 +85,22 @@ export default function EmbedView(props: NodeViewProps) {
radius="md"
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
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">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Embed {getEmbedProviderById(provider).name}
{t("Embed {{provider}}", {
provider: getEmbedProviderById(provider).name,
})}
</Text>
</div>
</Card>
@ -92,15 +108,18 @@ export default function EmbedView(props: NodeViewProps) {
<Popover.Dropdown bg="var(--mantine-color-body)">
<form onSubmit={embedForm.onSubmit(onSubmit)}>
<FocusTrap active={true}>
<TextInput placeholder={`Enter ${getEmbedProviderById(provider).name} link to embed`}
key={embedForm.key('url')}
{... embedForm.getInputProps('url')}
data-autofocus
<TextInput
placeholder={t("Enter {{provider}} link to embed", {
provider: getEmbedProviderById(provider).name,
})}
key={embedForm.key("url")}
{...embedForm.getInputProps("url")}
data-autofocus
/>
</FocusTrap>
<Group justify="center" mt="xs">
<Button type="submit">Embed link</Button>
<Button type="submit">{t("Embed link")}</Button>
</Group>
</form>
</Popover.Dropdown>

View File

@ -1,4 +1,4 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Button,
@ -7,27 +7,29 @@ import {
Image,
Text,
useComputedColorScheme,
} from '@mantine/core';
import { useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { svgStringToFile } from '@/lib';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
import { IAttachment } from '@/lib/types';
import ReactClearModal from 'react-clear-modal';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
import { lazy } from 'react';
import { Suspense } from 'react';
} from "@mantine/core";
import { useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
import { IAttachment } from "@/lib/types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
const Excalidraw = lazy(() =>
import('@excalidraw/excalidraw').then((module) => ({
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
}))
})),
);
export default function ExcalidrawView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
@ -46,11 +48,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import('@excalidraw/excalidraw');
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
@ -67,7 +69,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
return;
}
const { exportToSvg } = await import('@excalidraw/excalidraw');
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
@ -83,10 +85,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
svgString = svgString.replace(
/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 pageId = editor.storage?.pageId;
@ -112,7 +114,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
<NodeViewWrapper>
<ReactClearModal
style={{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 0,
zIndex: 200,
}}
@ -122,7 +124,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
contentProps={{
style: {
padding: 0,
width: '90vw',
width: "90vw",
},
}}
>
@ -132,14 +134,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSave} size={'compact-sm'}>
Save & Exit
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={close} color="red" size={'compact-sm'}>
Exit
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
<div style={{ height: '90vh' }}>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
@ -154,7 +156,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</ReactClearModal>
{src ? (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
@ -163,8 +165,8 @@ export default function ExcalidrawView(props: NodeViewProps) {
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
@ -175,7 +177,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
color="gray"
mx="xs"
style={{
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
}}
@ -190,20 +192,20 @@ export default function ExcalidrawView(props: NodeViewProps) {
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
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">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit Excalidraw diagram
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
</Card>

View File

@ -17,8 +17,10 @@ import {
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -96,11 +98,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align image left">
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignImageLeft}
size="lg"
aria-label="Align image left"
aria-label={t("Align left")}
variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
@ -109,11 +111,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image center">
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignImageCenter}
size="lg"
aria-label="Align image center"
aria-label={t("Align center")}
variant={
editor.isActive("image", { align: "center" })
? "light"
@ -124,11 +126,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image right">
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignImageRight}
size="lg"
aria-label="Align image right"
aria-label={t("Align right")}
variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}

View File

@ -1,8 +1,9 @@
import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -23,7 +24,9 @@ export const uploadImageAction = handleImageUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}

View File

@ -3,11 +3,13 @@ import { Button, Group, TextInput } from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
import { useTranslation } from "react-i18next";
export const LinkEditorPanel = ({
onSetLink,
initialUrl,
}: LinkEditorPanelProps) => {
const { t } = useTranslation();
const state = useLinkEditorState({
onSetLink,
initialUrl,
@ -20,12 +22,12 @@ export const LinkEditorPanel = ({
<TextInput
leftSection={<IconLink size={16} />}
variant="filled"
placeholder="Paste link"
placeholder={t("Paste link")}
value={state.url}
onChange={state.onChange}
/>
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
Save
{t("Save")}
</Button>
</Group>
</form>

View File

@ -7,6 +7,7 @@ import {
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export type LinkPreviewPanelProps = {
url: string;
@ -19,6 +20,8 @@ export const LinkPreviewPanel = ({
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
@ -42,13 +45,13 @@ export const LinkPreviewPanel = ({
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label="Edit link">
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove link">
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>

View File

@ -8,8 +8,10 @@ import classes from "./math.module.css";
import { v4 } from "uuid";
import { IconTrashX } from "@tabler/icons-react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
export default function MathBlockView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
@ -94,9 +96,9 @@ export default function MathBlockView(props: NodeViewProps) {
></div>
{((isEditing && !preview?.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>
</Popover.Target>
<Popover.Dropdown>

View File

@ -6,8 +6,10 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Popover, Textarea } from "@mantine/core";
import classes from "./math.module.css";
import { v4 } from "uuid";
import { useTranslation } from "react-i18next";
export default function MathInlineView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
@ -84,9 +86,9 @@ export default function MathInlineView(props: NodeViewProps) {
></div>
{((isEditing && !preview?.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>
</Popover.Target>
<Popover.Dropdown p={"xs"}>

View File

@ -13,6 +13,7 @@ import {
} from "@mantine/core";
import classes from "./slash-menu.module.css";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
const CommandList = ({
items,
@ -25,6 +26,7 @@ const CommandList = ({
editor: any;
range: any;
}) => {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
@ -104,18 +106,17 @@ const CommandList = ({
<ActionIcon
variant="default"
component="div"
aria-label={item.title}
>
<item.icon size={18} />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.title}
{t(item.title)}
</Text>
<Text c="dimmed" size="xs">
{item.description}
{t(item.description)}
</Text>
</div>
</Group>

View File

@ -13,9 +13,11 @@ import {
IconRowRemove,
IconSquareToggle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ view, state, from }: ShouldShowProps) => {
if (!state) {
@ -58,45 +60,45 @@ export const TableCellMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Merge cells">
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
variant="default"
size="lg"
aria-label="Merge cells"
aria-label={t("Merge cells")}
>
<IconBoxMargin size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Split cell">
<Tooltip position="top" label={t("Split cell")}>
<ActionIcon
onClick={splitCell}
variant="default"
size="lg"
aria-label="Split cell"
aria-label={t("Split cell")}
>
<IconSquareToggle size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
aria-label={t("Delete column")}
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
aria-label={t("Delete row")}
>
<IconRowRemove size={18} />
</ActionIcon>

View File

@ -21,9 +21,11 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export const TableMenu = React.memo(
({ editor }: EditorMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -111,79 +113,80 @@ export const TableMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Add left column">
<Tooltip position="top" label={t("Add left column")}
>
<ActionIcon
onClick={addColumnLeft}
variant="default"
size="lg"
aria-label="Add left column"
aria-label={t("Add left column")}
>
<IconColumnInsertLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add right column">
<Tooltip position="top" label={t("Add right column")}>
<ActionIcon
onClick={addColumnRight}
variant="default"
size="lg"
aria-label="Add right column"
aria-label={t("Add right column")}
>
<IconColumnInsertRight size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
aria-label={t("Delete column")}
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row above">
<Tooltip position="top" label={t("Add row above")}>
<ActionIcon
onClick={addRowAbove}
variant="default"
size="lg"
aria-label="Add row above"
aria-label={t("Add row above")}
>
<IconRowInsertTop size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row below">
<Tooltip position="top" label={t("Add row below")}>
<ActionIcon
onClick={addRowBelow}
variant="default"
size="lg"
aria-label="Add row below"
aria-label={t("Add row below")}
>
<IconRowInsertBottom size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
aria-label={t("Delete row")}
>
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete table">
<Tooltip position="top" label={t("Delete table")}>
<ActionIcon
onClick={deleteTable}
variant="default"
size="lg"
color="red"
aria-label="Delete table"
aria-label={t("Delete table")}
>
<IconTrashX size={18} />
</ActionIcon>

View File

@ -1,8 +1,9 @@
import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -24,11 +25,12 @@ export const uploadVideoAction = handleVideoUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
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 true;
},
});

View File

@ -17,8 +17,10 @@ import {
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -96,11 +98,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align video left">
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignVideoLeft}
size="lg"
aria-label="Align video left"
aria-label={t("Align left")}
variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
@ -109,11 +111,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video center">
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignVideoCenter}
size="lg"
aria-label="Align video center"
aria-label={t("Align center")}
variant={
editor.isActive("video", { align: "center" })
? "light"
@ -124,11 +126,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video right">
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignVideoRight}
size="lg"
aria-label="Align video right"
aria-label={t("Align right")}
variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}

View File

@ -35,7 +35,7 @@ import {
CustomCodeBlock,
Drawio,
Excalidraw,
Embed
Embed,
} from "@docmost/editor-ext";
import {
randomElement,
@ -64,6 +64,7 @@ import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import i18n from "@/i18n.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -94,13 +95,13 @@ export const mainExtensions = [
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
return i18n.t("Heading {{level}}", { level: node.attrs.level });
}
if (node.type.name === "detailsSummary") {
return "Toggle title";
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
return 'Write anything. Enter "/" for commands';
return i18n.t('Write anything. Enter "/" for commands');
}
},
includeChildren: true,
@ -184,7 +185,7 @@ export const mainExtensions = [
}),
Embed.configure({
view: EmbedView,
})
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -19,6 +19,7 @@ import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export interface TitleEditorProps {
pageId: string;
@ -35,6 +36,7 @@ export function TitleEditor({
spaceSlug,
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const {
@ -59,7 +61,7 @@ export function TitleEditor({
}),
Text,
Placeholder.configure({
placeholder: "Untitled",
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({

View File

@ -4,8 +4,10 @@ import React, { useState } from "react";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useParams } from "react-router-dom";
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
import { useTranslation } from "react-i18next";
export default function AddGroupMemberModal() {
const { t } = useTranslation();
const { groupId } = useParams();
const [opened, { open, close }] = useDisclosure(false);
const [userIds, setUserIds] = useState<string[]>([]);
@ -27,19 +29,19 @@ export default function AddGroupMemberModal() {
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" />
<MultiUserSelect
label={"Add group members"}
label={t("Add group members")}
onChange={handleMultiSelectChange}
/>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
{t("Add")}
</Button>
</Group>
</Modal>

View File

@ -5,6 +5,7 @@ import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
@ -14,6 +15,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() {
const { t } = useTranslation();
const createGroupMutation = useCreateGroupMutation();
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
@ -52,16 +54,16 @@ export function CreateGroupForm() {
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
label={t("Group name")}
placeholder={t("e.g Developers")}
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
label={t("Group description")}
placeholder={t("e.g Group for developers")}
variant="filled"
autosize
minRows={2}
@ -70,13 +72,13 @@ export function CreateGroupForm() {
/>
<MultiUserSelect
label={"Add group members"}
label={t("Add group members")}
onChange={handleMultiSelectChange}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
<Button type="submit">{t("Create")}</Button>
</Group>
</form>
</Box>

View File

@ -1,15 +1,17 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateGroupModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
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" />
<CreateGroupForm />
</Modal>

View File

@ -7,6 +7,7 @@ import {
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
@ -18,6 +19,7 @@ interface EditGroupFormProps {
onClose?: () => void;
}
export function EditGroupForm({ onClose }: EditGroupFormProps) {
const { t } = useTranslation();
const updateGroupMutation = useUpdateGroupMutation();
const { isSuccess } = updateGroupMutation;
const { groupId } = useParams();
@ -60,16 +62,16 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
label={t("Group name")}
placeholder={t("e.g Developers")}
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
label={t("Group description")}
placeholder={t("e.g Group for developers")}
variant="filled"
autosize
minRows={2}
@ -79,7 +81,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Save</Button>
<Button type="submit">{t("Save")}</Button>
</Group>
</form>
</Box>

View File

@ -1,5 +1,6 @@
import { Divider, Modal } from "@mantine/core";
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
import { useTranslation } from "react-i18next";
interface EditGroupModalProps {
opened: boolean;
@ -10,9 +11,11 @@ export default function EditGroupModal({
opened,
onClose,
}: EditGroupModalProps) {
const { t } = useTranslation();
return (
<>
<Modal opened={opened} onClose={onClose} title="Edit group">
<Modal opened={opened} onClose={onClose} title={t("Edit group")}>
<Divider size="xs" mb="xs" />
<EditGroupForm onClose={onClose} />
</Modal>

View File

@ -9,8 +9,10 @@ import { IconDots, IconTrash } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
export default function GroupActionMenu() {
const { t } = useTranslation();
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const deleteGroupMutation = useDeleteGroupMutation();
@ -24,15 +26,16 @@ export default function GroupActionMenu() {
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Delete group",
title: t("Delete group"),
children: (
<Text size="sm">
Are you sure you want to delete this group? Members will lose access
to resources this group has access to.
{t(
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
)}
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onDelete,
});
@ -57,7 +60,7 @@ export default function GroupActionMenu() {
<Menu.Dropdown>
<Menu.Item onClick={open} disabled={group.isDefault}>
Edit group
{t("Edit group")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@ -66,7 +69,7 @@ export default function GroupActionMenu() {
disabled={group.isDefault}
leftSection={<IconTrash size={16} stroke={2} />}
>
Delete group
{t("Delete group")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -7,6 +7,7 @@ import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function GroupDetails() {
const { groupId } = useParams();

View File

@ -1,11 +1,15 @@
import {Table, Group, Text, Anchor} from "@mantine/core";
import {useGetGroupsQuery} from "@/features/group/queries/group-query";
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import React from "react";
import {Link} from "react-router-dom";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import { Link } from "react-router-dom";
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() {
const {data, isLoading} = useGetGroupsQuery();
const { t } = useTranslation();
const { data, isLoading } = useGetGroupsQuery();
return (
<>
@ -14,13 +18,13 @@ export default function GroupList() {
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
<Table.Th>{t("Group")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
{data?.items.map((group: IGroup, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
@ -34,7 +38,7 @@ export default function GroupList() {
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle/>
<IconGroupCircle />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
@ -46,7 +50,6 @@ export default function GroupList() {
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
@ -54,12 +57,12 @@ export default function GroupList() {
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap"
whiteSpace: "nowrap",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
{formatMemberCount(group.memberCount, t)}
</Anchor>
</Table.Td>
</Table.Tr>

View File

@ -1,20 +1,23 @@
import {Group, Table, Text, Badge, Menu, ActionIcon} from "@mantine/core";
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
import {
useGroupMembersQuery,
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import {useParams} from "react-router-dom";
import { useParams } from "react-router-dom";
import React from "react";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.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() {
const {groupId} = useParams();
const {data, isLoading} = useGroupMembersQuery(groupId);
const { t } = useTranslation();
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const {isAdmin} = useUserRole();
const { isAdmin } = useUserRole();
const onRemove = async (userId: string) => {
const memberToRemove = {
@ -26,16 +29,17 @@ export default function GroupMembersList() {
const openRemoveModal = (userId: string) =>
modals.openConfirmModal({
title: "Remove group member",
title: t("Remove group member"),
children: (
<Text size="sm">
Are you sure you want to remove this user from the group? The user
will lose access to resources this group has access to.
{t(
"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>
),
centered: true,
labels: {confirm: "Delete", cancel: "Cancel"},
confirmProps: {color: "red"},
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
@ -46,18 +50,21 @@ export default function GroupMembersList() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500}>
{user.name}
@ -68,11 +75,9 @@ export default function GroupMembersList() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
@ -85,13 +90,12 @@ export default function GroupMembersList() {
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -4,6 +4,7 @@ import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query.ts";
import { IGroup } from "@/features/group/types/group.types.ts";
import { IconUsersGroup } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface MultiGroupSelectProps {
onChange: (value: string[]) => void;
@ -29,6 +30,7 @@ export function MultiGroupSelect({
description,
mt,
}: MultiGroupSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: groups, isLoading } = useGetGroupsQuery({
@ -66,8 +68,8 @@ export function MultiGroupSelect({
hidePickedOptions
maxDropdownHeight={300}
description={description}
label={label || "Add groups"}
placeholder="Search for groups"
label={label || t("Add groups")}
placeholder={t("Search for groups")}
mt={mt}
searchable
searchValue={searchValue}
@ -75,7 +77,7 @@ export function MultiGroupSelect({
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No group found"
nothingFoundMessage={t("No group found")}
maxValues={50}
/>
);

View File

@ -4,6 +4,7 @@ import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace
import { IUser } from "@/features/user/types/user.types.ts";
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
interface MultiUserSelectProps {
onChange: (value: string[]) => void;
@ -29,6 +30,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
@ -65,15 +67,15 @@ export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label={label || "Add members"}
placeholder="Search for users"
label={label || t("Add members")}
placeholder={t("Search for users")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No user found"
nothingFoundMessage={t("No user found")}
maxValues={50}
/>
);

View File

@ -1,14 +1,17 @@
import { Text, Tabs, Space } from "@mantine/core";
import { IconClockHour3 } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes.tsx";
import { useTranslation } from "react-i18next";
export default function HomeTabs() {
const { t } = useTranslation();
return (
<Tabs defaultValue="recent">
<Tabs.List>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
Recently updated
{t("Recently updated")}
</Text>
</Tabs.Tab>
</Tabs.List>

View File

@ -16,12 +16,14 @@ import {
} from "@/features/editor/atoms/editor-atoms";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface Props {
pageId: string;
}
function HistoryList({ pageId }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const {
data: pageHistoryList,
@ -36,14 +38,15 @@ function HistoryList({ pageId }: Props) {
const confirmModal = () =>
modals.openConfirmModal({
title: "Please confirm your action",
title: t("Please confirm your action"),
children: (
<Text size="sm">
Are you sure you want to restore this version? Any changes not
versioned will be lost.
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: "Confirm", cancel: "Cancel" },
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
@ -60,7 +63,7 @@ function HistoryList({ pageId }: Props) {
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: "Successfully restored" });
notifications.show({ message: t("Successfully restored") });
}
}, [activeHistoryData]);
@ -79,11 +82,11 @@ function HistoryList({ pageId }: Props) {
}
if (isError) {
return <div>Error loading page history.</div>;
return <div>{t("Error loading page history.")}</div>;
}
if (!pageHistoryList || pageHistoryList.items.length === 0) {
return <>No page history saved yet.</>;
return <>{t("No page history saved yet.")}</>;
}
return (
@ -104,14 +107,14 @@ function HistoryList({ pageId }: Props) {
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
Restore
{t("Restore")}
</Button>
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
Cancel
{t("Cancel")}
</Button>
</Group>
</div>

View File

@ -2,11 +2,13 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import { useTranslation } from "react-i18next";
interface Props {
pageId: string;
}
export default function HistoryModal({ pageId }: Props) {
const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
return (
@ -21,7 +23,7 @@ export default function HistoryModal({ pageId }: Props) {
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
Page history
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />

View File

@ -1,11 +1,13 @@
import { usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
import { HistoryEditor } from '@/features/page-history/components/history-editor';
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next";
interface HistoryProps {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
if (isLoading) {
@ -13,13 +15,15 @@ function HistoryView({ historyId }: HistoryProps) {
}
if (isError || !data) {
return <div>Error fetching page data.</div>;
return <div>{t("Error fetching page data.")}</div>;
}
return (data &&
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
return (
data && (
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
)
);
}

View File

@ -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 { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
interface PageHeaderMenuProps {
@ -53,6 +54,7 @@ interface PageActionMenuProps {
readOnly?: boolean;
}
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { t } = useTranslation();
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { pageSlug, spaceSlug } = useParams();
@ -69,7 +71,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
notifications.show({ message: t("Link copied") });
};
const handlePrint = () => {
@ -107,13 +109,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconLink size={16} />}
onClick={handleCopyLink}
>
Copy link
{t("Copy link")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap">
<PageWidthToggle label="Full width" />
<PageWidthToggle label={t("Full width")} />
</Group>
</Menu.Item>
@ -121,7 +123,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
Page history
{t("Page history")}
</Menu.Item>
<Menu.Divider />
@ -130,14 +132,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconFileExport size={16} />}
onClick={openExportModal}
>
Export
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconPrinter size={16} />}
onClick={handlePrint}
>
Print PDF
{t("Print PDF")}
</Menu.Item>
{!readOnly && (
@ -148,7 +150,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
Delete
{t("Delete")}
</Menu.Item>
</>
)}

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import * as React from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface PageExportModalProps {
pageId: string;
@ -16,6 +17,7 @@ export default function PageExportModal({
open,
onClose,
}: PageExportModalProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
@ -24,7 +26,7 @@ export default function PageExportModal({
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
message: t("Export failed:") + err.response?.data.message,
color: "red",
});
console.error("export error", err);
@ -48,32 +50,29 @@ export default function PageExportModal({
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export page</Modal.Title>
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">Include subpages</Text>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
{t("Cancel")}
</Button>
<Button onClick={handleExport}>Export</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
@ -86,6 +85,8 @@ interface ExportFormatSelection {
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
return (
<Select
data={[
@ -98,7 +99,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
aria-label={t("Select export format")}
/>
);
}

View File

@ -12,6 +12,7 @@ import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React from "react";
import { useTranslation } from "react-i18next";
interface PageImportModalProps {
spaceId: string;
@ -24,6 +25,7 @@ export default function PageImportModal({
open,
onClose,
}: PageImportModalProps) {
const { t } = useTranslation();
return (
<>
<Modal.Root
@ -38,7 +40,7 @@ export default function PageImportModal({
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Import pages</Modal.Title>
<Modal.Title fw={500}>{t("Import pages")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
@ -55,6 +57,7 @@ interface ImportFormatSelection {
onClose: () => void;
}
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const handleFileUpload = async (selectedFiles: File[]) => {
@ -65,8 +68,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
onClose();
const alert = notifications.show({
title: "Importing pages",
message: "Page import is in progress. Please do not close this tab.",
title: t("Importing pages"),
message: t("Page import is in progress. Please do not close this tab."),
loading: true,
autoClose: false,
});
@ -92,13 +95,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree);
}
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
notifications.update({
id: alert,
color: "teal",
title: `Successfully imported ${pageCountText}`,
message: "Your import is complete.",
title: `${t("Successfully imported")} ${pageCountText}`,
message: t("Your import is complete."),
icon: <IconCheck size={18} />,
loading: false,
autoClose: 5000,
@ -107,8 +111,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
notifications.update({
id: alert,
color: "red",
title: `Failed to import pages`,
message: "Unable to import pages. Please try again.",
title: t("Failed to import pages"),
message: t("Unable to import pages. Please try again."),
icon: <IconX size={18} />,
loading: false,
autoClose: 5000,

View File

@ -1,22 +1,25 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type UseDeleteModalProps = {
onConfirm: () => void;
};
export function useDeletePageModal() {
const { t } = useTranslation();
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
title: t("Are you sure you want to delete this page?"),
children: (
<Text size="sm">
Are you sure you want to delete this page? This will delete its
children and page history. This action is irreversible.
{t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)}
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm,
});

View File

@ -25,6 +25,7 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useTranslation } from "react-i18next";
export function usePageQuery(
pageInput: Partial<IPageInput>,
@ -38,11 +39,12 @@ export function usePageQuery(
}
export function useCreatePageMutation() {
const { t } = useTranslation();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
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() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
onSuccess: () => {
notifications.show({ message: "Page deleted successfully" });
notifications.show({ message: t("Page deleted successfully") });
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
notifications.show({ message: t("Failed to delete page"), color: "red" });
},
});
}

View File

@ -52,6 +52,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
interface SpaceTreeProps {
@ -405,6 +406,7 @@ interface NodeMenuProps {
}
function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
@ -415,7 +417,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
notifications.show({ message: t("Link copied") });
};
return (
@ -446,7 +448,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
handleCopyLink();
}}
>
Copy link
{t("Copy link")}
</Menu.Item>
<Menu.Item
@ -457,7 +459,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openExportModal();
}}
>
Export page
{t("Export page")}
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
@ -475,7 +477,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
{t("Delete")}
</Menu.Item>
</>
)}

View File

@ -6,11 +6,13 @@ import { useNavigate } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
@ -65,16 +67,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}}
>
<Spotlight.Search
placeholder="Search..."
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{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 && (
<Spotlight.Empty>No results found...</Spotlight.Empty>
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}

View File

@ -5,6 +5,7 @@ import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
import { SpaceRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
interface AddSpaceMemberModalProps {
spaceId: string;
@ -12,6 +13,7 @@ interface AddSpaceMemberModalProps {
export default function AddSpaceMembersModal({
spaceId,
}: AddSpaceMemberModalProps) {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(SpaceRole.WRITER);
@ -48,8 +50,8 @@ export default function AddSpaceMembersModal({
return (
<>
<Button onClick={open}>Add space members</Button>
<Modal opened={opened} onClose={close} title="Add space members">
<Button onClick={open}>{t("Add space members")}</Button>
<Modal opened={opened} onClose={close} title={t("Add space members")}>
<Divider size="xs" mb="xs" />
<Stack>
@ -57,13 +59,13 @@ export default function AddSpaceMembersModal({
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
label="Select role"
label={t("Select role")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
{t("Add")}
</Button>
</Group>
</Modal>

View File

@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { computeSpaceSlug } from "@/lib";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
@ -23,6 +24,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateSpaceForm() {
const { t } = useTranslation();
const createSpaceMutation = useCreateSpaceMutation();
const navigate = useNavigate();
@ -74,8 +76,8 @@ export function CreateSpaceForm() {
<TextInput
withAsterisk
id="name"
label="Space name"
placeholder="e.g Product Team"
label={t("Space name")}
placeholder={t("e.g Product Team")}
variant="filled"
{...form.getInputProps("name")}
/>
@ -83,16 +85,16 @@ export function CreateSpaceForm() {
<TextInput
withAsterisk
id="slug"
label="Space slug"
placeholder="e.g product"
label={t("Space slug")}
placeholder={t("e.g product")}
variant="filled"
{...form.getInputProps("slug")}
/>
<Textarea
id="description"
label="Space description"
placeholder="e.g Space for product team"
label={t("Space description")}
placeholder={t("e.g Space for product team")}
variant="filled"
autosize
minRows={2}
@ -102,7 +104,7 @@ export function CreateSpaceForm() {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
<Button type="submit">{t("Create")}</Button>
</Group>
</form>
</Box>

View File

@ -1,15 +1,17 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateSpaceModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
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" />
<CreateSpaceForm />
</Modal>

View File

@ -4,6 +4,7 @@ import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
@ -24,6 +25,7 @@ interface EditSpaceFormProps {
readOnly?: boolean;
}
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const { t } = useTranslation();
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
@ -65,8 +67,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<Stack>
<TextInput
id="name"
label="Name"
placeholder="e.g Sales"
label={t("Name")}
placeholder={t("e.g Sales")}
variant="filled"
readOnly={readOnly}
{...form.getInputProps("name")}
@ -74,7 +76,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<TextInput
id="slug"
label="Slug"
label={t("Slug")}
variant="filled"
readOnly={readOnly}
{...form.getInputProps("slug")}
@ -82,8 +84,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<Textarea
id="description"
label="Description"
placeholder="e.g Space for sales team to collaborate"
label={t("Description")}
placeholder={t("e.g Space for sales team to collaborate")}
variant="filled"
readOnly={readOnly}
autosize
@ -96,7 +98,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
{!readOnly && (
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
{t("Save")}
</Button>
</Group>
)}

View File

@ -6,6 +6,7 @@ import { useSearchSuggestionsQuery } from "@/features/search/queries/search-quer
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
@ -30,6 +31,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
@ -83,14 +85,14 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const updatedUserGroups = mergeItemsIntoGroups(
data,
userItems,
"Select a user",
t("Select a user"),
);
// Merge group items into groups
const finalData = mergeItemsIntoGroups(
updatedUserGroups,
groupItems,
"Select a group",
t("Select a group"),
);
setData(finalData);
@ -103,8 +105,8 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add members"
placeholder="Search for users and groups"
label={t("Add members")}
placeholder={t("Search for users and groups")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}

View File

@ -9,6 +9,7 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
interface SpaceSettingsModalProps {
spaceId: string;
@ -17,11 +18,12 @@ interface SpaceSettingsModalProps {
}
export default function SpaceSettingsModal({
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const {data: space, isLoading} = useSpaceQuery(spaceId);
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
@ -50,10 +52,10 @@ export default function SpaceSettingsModal({
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
Settings
{t("Settings")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="members">
Members
{t("Members")}
</Tabs.Tab>
</Tabs.List>

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import { Avatar, Group, Select, SelectProps, Text } from '@mantine/core';
import { useGetSpacesQuery } from '@/features/space/queries/space-query.ts';
import { ISpace } from '../../types/space.types';
import { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
interface SpaceSelectProps {
onChange: (value: string) => void;
@ -10,7 +11,7 @@ interface SpaceSelectProps {
label?: string;
}
const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<div>
@ -20,7 +21,8 @@ const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
);
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const [searchValue, setSearchValue] = useState('');
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: spaces, isLoading } = useGetSpacesQuery({
query: debouncedQuery,
@ -41,7 +43,7 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const filteredSpaceData = spaceData.filter(
(user) =>
!data.find((existingUser) => existingUser.value === user.value)
!data.find((existingUser) => existingUser.value === user.value),
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
@ -53,14 +55,14 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
renderOption={renderSelectOption}
maxDropdownHeight={300}
//label={label || 'Select space'}
placeholder="Search for spaces"
placeholder={t("Search for spaces")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No space found"
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width: 300, withinPortal: false }}

View File

@ -35,10 +35,12 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { useTranslation } from "react-i18next";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
export function SpaceSidebar() {
const { t } = useTranslation();
const [tree] = useAtom(treeApiAtom);
const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] =
@ -89,7 +91,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Overview</span>
<span>{t("Overview")}</span>
</div>
</UnstyledButton>
@ -100,7 +102,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Search</span>
<span>{t("Search")}</span>
</div>
</UnstyledButton>
@ -111,7 +113,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Space settings</span>
<span>{t("Space settings")}</span>
</div>
</UnstyledButton>
@ -129,7 +131,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>New page</span>
<span>{t("New page")}</span>
</div>
</UnstyledButton>
)}
@ -139,7 +141,7 @@ export function SpaceSidebar() {
<div className={clsx(classes.section, classes.sectionPages)}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
Pages
{t("Pages")}
</Text>
{spaceAbility.can(
@ -149,12 +151,12 @@ export function SpaceSidebar() {
<Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
<Tooltip label="Create page" withArrow position="right">
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label="Create page"
aria-label={t("Create page")}
>
<IconPlus />
</ActionIcon>
@ -191,6 +193,7 @@ interface SpaceMenuProps {
onSpaceSettings: () => void;
}
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const { t } = useTranslation();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
@ -201,11 +204,15 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
<Menu width={200} shadow="md" withArrow>
<Menu.Target>
<Tooltip
label="Import pages & space settings"
label={t("Import pages & space settings")}
withArrow
position="top"
>
<ActionIcon variant="default" size={18} aria-label="Space menu">
<ActionIcon
variant="default"
size={18}
aria-label={t("Space menu")}
>
<IconDots />
</ActionIcon>
</Tooltip>
@ -216,7 +223,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
onClick={openImportModal}
leftSection={<IconArrowDown size={16} />}
>
Import pages
{t("Import pages")}
</Menu.Item>
<Menu.Item
@ -232,7 +239,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
onClick={onSpaceSettings}
leftSection={<IconSettings size={16} />}
>
Space settings
{t("Space settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -5,12 +5,14 @@ import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
import { useTranslation } from "react-i18next";
interface SpaceDetailsProps {
spaceId: string;
readOnly?: boolean;
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
@ -20,7 +22,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{space && (
<div>
<Text my="md" fw={600}>
Details
{t("Details")}
</Text>
<EditSpaceForm space={space} readOnly={readOnly} />
@ -33,12 +35,12 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<div>
<Text size="md">Export space</Text>
<Text size="sm" c="dimmed">
Export all pages and attachments in this space
{t("Export all pages and attachments in this space.")}
</Text>
</div>
<Button onClick={openExportModal}>
Export
{t("Export")}
</Button>
</Group>
@ -46,9 +48,9 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Delete space</Text>
<Text size="md">{t("Delete space")}</Text>
<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>
</div>

View File

@ -5,8 +5,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
export default function SpaceGrid() {
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery();
const cards = data?.items.map((space, index) => (
@ -33,7 +35,7 @@ export default function SpaceGrid() {
</Text>
<Text c="dimmed" size="xs" fw={700} mt="md">
{formatMemberCount(space.memberCount)}
{formatMemberCount(space.memberCount, t)}
</Text>
</Card>
));
@ -41,7 +43,7 @@ export default function SpaceGrid() {
return (
<>
<Text fz="sm" fw={500} mb={"md"}>
Spaces you belong to
{t("Spaces you belong to")}
</Text>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>

View File

@ -3,8 +3,10 @@ import { IconClockHour3 } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes.tsx";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTranslation } from "react-i18next";
export default function SpaceHomeTabs() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@ -13,7 +15,7 @@ export default function SpaceHomeTabs() {
<Tabs.List>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
Recently updated
{t("Recently updated")}
</Text>
</Tabs.Tab>
</Tabs.List>

View File

@ -1,13 +1,15 @@
import {Table, Group, Text, Avatar} from "@mantine/core";
import React, {useState} from "react";
import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import {useDisclosure} from "@mantine/hooks";
import {formatMemberCount} from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
export default function SpaceList() {
const {data, isLoading} = useGetSpacesQuery();
const [opened, {open, close}] = useDisclosure(false);
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => {
@ -22,8 +24,8 @@ export default function SpaceList() {
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
<Table.Th>{t("Space")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -31,7 +33,7 @@ export default function SpaceList() {
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{cursor: "pointer"}}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
@ -51,9 +53,10 @@ export default function SpaceList() {
</div>
</Group>
</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.Tr>
))}

View File

@ -1,21 +1,22 @@
import {Group, Table, Text, Menu, ActionIcon} from "@mantine/core";
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import React from "react";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import {IRemoveSpaceMember} from "@/features/space/types/space.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getSpaceRoleLabel,
spaceRoleData,
} 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";
@ -25,10 +26,12 @@ interface SpaceMembersProps {
}
export default function SpaceMembersList({
spaceId,
readOnly,
}: SpaceMembersProps) {
const {data, isLoading} = useSpaceMembersQuery(spaceId);
spaceId,
readOnly,
}: SpaceMembersProps) {
const { t } = useTranslation();
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -79,16 +82,17 @@ export default function SpaceMembersList({
const openRemoveModal = (memberId: string, type: MemberType) =>
modals.openConfirmModal({
title: "Remove space member",
title: t("Remove space member"),
children: (
<Text size="sm">
Are you sure you want to remove this user from the space? The user
will lose all access to this space.
{t(
"Are you sure you want to remove this user from the space? The user will lose all access to this space.",
)}
</Text>
),
centered: true,
labels: {confirm: "Remove", cancel: "Cancel"},
confirmProps: {color: "red"},
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type),
});
@ -99,8 +103,8 @@ export default function SpaceMembersList({
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>{t("Member")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@ -117,7 +121,7 @@ export default function SpaceMembersList({
/>
)}
{member.type === "group" && <IconGroupCircle/>}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
@ -127,7 +131,7 @@ export default function SpaceMembersList({
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
</Text>
</div>
</Group>
@ -161,7 +165,7 @@ export default function SpaceMembersList({
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
@ -171,7 +175,7 @@ export default function SpaceMembersList({
openRemoveModal(member.id, member.type)
}
>
Remove space member
{t("Remove space member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -9,7 +9,7 @@ export const spaceRoleData: IRoleData[] = [
{
label: "Can edit",
value: SpaceRole.WRITER,
description: "Can create and edit pages in space.",
description: "Can create and edit pages in space",
},
{
label: "Can view",

View File

@ -5,10 +5,12 @@ import { useAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { FileButton, Tooltip } from "@mantine/core";
import { uploadAvatar } from "@/features/user/services/user-service.ts";
import { useTranslation } from "react-i18next";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountAvatar() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,7 +38,7 @@ export default function AccountAvatar() {
<>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
{(props) => (
<Tooltip label="Change photo" position="bottom">
<Tooltip label={t("Change photo")} position="bottom">
<CustomAvatar
{...props}
component="button"

View File

@ -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"
/>
);
}

View File

@ -8,9 +8,10 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react";
import { TextInput, Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
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>;
@ -18,6 +19,7 @@ type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,12 +38,12 @@ export default function AccountNameForm() {
const updatedUser = await updateUser(data);
setUser(updatedUser);
notifications.show({
message: "Updated successfully",
message: t("Updated successfully"),
});
} catch (err) {
console.log(err);
notifications.show({
message: "Failed to update data",
message: t("Failed to update data"),
color: "red",
});
}
@ -53,13 +55,13 @@ export default function AccountNameForm() {
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="name"
label="Name"
placeholder="Your name"
label={t("Name")}
placeholder={t("Your name")}
variant="filled"
{...form.getInputProps("name")}
/>
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
Save
{t("Save")}
</Button>
</form>
);

View File

@ -5,14 +5,17 @@ import {
Select,
MantineColorScheme,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountTheme() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Theme</Text>
<Text size="md">{t("Theme")}</Text>
<Text size="sm" c="dimmed">
Choose your preferred color scheme.
{t("Choose your preferred color scheme.")}
</Text>
</div>
@ -22,6 +25,7 @@ export default function AccountTheme() {
}
function ThemeSwitcher() {
const { t } = useTranslation();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const handleChange = (value: MantineColorScheme) => {
@ -30,11 +34,11 @@ function ThemeSwitcher() {
return (
<Select
label="Select theme"
label={t("Select theme")}
data={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "auto", label: "System settings" },
{ value: "light", label: t("Light") },
{ value: "dark", label: t("Dark") },
{ value: "auto", label: t("System settings") },
]}
value={colorScheme}
onChange={handleChange}

View File

@ -13,15 +13,17 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useTranslation } from "react-i18next";
export default function ChangeEmail() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Email</Text>
<Text size="md">{t("Email")}</Text>
<Text size="sm" c="dimmed">
{currentUser?.user.email}
</Text>
@ -29,13 +31,15 @@ export default function ChangeEmail() {
{/*
<Button onClick={open} variant="default">
Change email
{t("Change email")}
</Button>
*/}
<Modal opened={opened} onClose={close} title="Change email" centered>
<Modal opened={opened} onClose={close} title={t("Change email")} centered>
<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>
<ChangeEmailForm />
</Modal>
@ -53,6 +57,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
function ChangeEmailForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -71,8 +76,8 @@ function ChangeEmailForm() {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Password"
placeholder="Enter your password"
label={t("Password")}
placeholder={t("Enter your password")}
variant="filled"
mb="md"
{...form.getInputProps("password")}
@ -80,16 +85,16 @@ function ChangeEmailForm() {
<TextInput
id="email"
label="Email"
description="Enter your new preferred email"
placeholder="New email"
label={t("Email")}
description={t("Enter your new preferred email")}
placeholder={t("New email")}
variant="filled"
mb="md"
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change email
{t("Change email")}
</Button>
</form>
);

View File

@ -6,25 +6,34 @@ import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { changePassword } from "@/features/auth/services/auth-service.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export default function ChangePassword() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Password</Text>
<Text size="md">{t("Password")}</Text>
<Text size="sm" c="dimmed">
You can change your password here.
{t("You can change your password here.")}
</Text>
</div>
<Button onClick={open} variant="default">
Change password
{t("Change password")}
</Button>
<Modal opened={opened} onClose={close} title="Change password" centered>
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
<Modal
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} />
</Modal>
</Group>
@ -44,6 +53,7 @@ interface ChangePasswordFormProps {
onClose?: () => void;
}
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -62,7 +72,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
newPassword: data.newPassword,
});
notifications.show({
message: "Password changed successfully",
message: t("Password changed successfully"),
});
onClose();
@ -78,9 +88,9 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Current password"
label={t("Current password")}
name="oldPassword"
placeholder="Enter your current password"
placeholder={t("Enter your current password")}
variant="filled"
mb="md"
data-autofocus
@ -88,8 +98,8 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
/>
<PasswordInput
label="New password"
placeholder="Enter your new password"
label={t("New password")}
placeholder={t("Enter your new password")}
variant="filled"
mb="md"
{...form.getInputProps("newPassword")}
@ -97,7 +107,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change password
{t("Change password")}
</Button>
</Group>
</form>

View File

@ -3,14 +3,17 @@ import { useAtom } from "jotai/index";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
export default function PageWidthPref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Full page width</Text>
<Text size="md">{t("Full page width")}</Text>
<Text size="sm" c="dimmed">
Choose your preferred page width.
{t("Choose your preferred page width.")}
</Text>
</div>
@ -24,6 +27,7 @@ interface PageWidthToggleProps {
label?: string;
}
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.preferences?.fullPageWidth,
@ -43,7 +47,7 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label="Toggle full page width"
aria-label={t("Toggle full page width")}
/>
);
}

View File

@ -11,6 +11,7 @@ export interface IUser {
invitedById: string;
lastLoginAt: string;
lastActiveAt: Date;
locale: string;
createdAt: Date;
updatedAt: Date;
role: string;

View File

@ -2,14 +2,19 @@ import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useTranslation } from "react-i18next";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const { data, isLoading, error } = useCurrentUser();
const { i18n } = useTranslation();
useEffect(() => {
if (data && data.user && data.workspace) {
setCurrentUser(data);
i18n.changeLanguage(
data.user.locale === "en" ? "en-US" : data.user.locale,
);
}
}, [data, isLoading]);

View File

@ -6,11 +6,13 @@ import {
useResendInvitationMutation,
useRevokeInvitationMutation,
} from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next";
interface Props {
invitationId: string;
}
export default function InviteActionMenu({ invitationId }: Props) {
const { t } = useTranslation();
const resendInvitationMutation = useResendInvitationMutation();
const revokeInvitationMutation = useRevokeInvitationMutation();
@ -24,15 +26,16 @@ export default function InviteActionMenu({ invitationId }: Props) {
const openRevokeModal = () =>
modals.openConfirmModal({
title: "Revoke invitation",
title: t("Revoke invitation"),
children: (
<Text size="sm">
Are you sure you want to revoke this invitation? The user will not be
able to join the workspace.
{t(
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.",
)}
</Text>
),
centered: true,
labels: { confirm: "Revoke", cancel: "Don't" },
labels: { confirm: t("Revoke"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
@ -54,14 +57,14 @@ export default function InviteActionMenu({ invitationId }: Props) {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onResend}>Resend invitation</Menu.Item>
<Menu.Item onClick={onResend}>{t("Resend invitation")}</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} stroke={2} />}
>
Revoke invitation
{t("Revoke invitation")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -5,11 +5,13 @@ import { UserRole } from "@/lib/types.ts";
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
interface Props {
onClose: () => void;
}
export function WorkspaceInviteForm({ onClose }: Props) {
const { t } = useTranslation();
const [emails, setEmails] = useState<string[]>([]);
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
const [groupIds, setGroupIds] = useState<string[]>([]);
@ -44,9 +46,11 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<TagsInput
mt="sm"
description="Enter valid email addresses separated by comma or space [max: 50]"
label="Invite by email"
placeholder="enter valid emails addresses"
description={t(
"Enter valid email addresses separated by comma or space max_50",
)}
label={t("Invite by email")}
placeholder={t("enter valid emails addresses")}
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={200}
@ -56,11 +60,17 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<Select
mt="sm"
description="Select role to assign to all invited members"
label="Select role"
placeholder="Choose a role"
description={t("Select role to assign to all invited members")}
label={t("Select role")}
placeholder={t("Choose a role")}
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}
allowDeselect={false}
checkIconPosition="right"
@ -69,8 +79,10 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<MultiGroupSelect
mt="sm"
description="Invited members will be granted access to spaces the groups can access"
label={"Add to groups"}
description={t(
"Invited members will be granted access to spaces the groups can access",
)}
label={t("Add to groups")}
onChange={handleGroupSelect}
/>
@ -79,7 +91,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
onClick={handleSubmit}
loading={createInvitationMutation.isPending}
>
Send invitation
{t("Send invitation")}
</Button>
</Group>
</Box>

View File

@ -1,19 +1,21 @@
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
export default function WorkspaceInviteModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Invite members</Button>
<Button onClick={open}>{t("Invite members")}</Button>
<Modal
size="550"
opened={opened}
onClose={close}
title="Invite new members"
title={t("Invite new members")}
centered
>
<Divider size="xs" mb="xs" />

View File

@ -2,8 +2,10 @@ import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const [inviteLink, setInviteLink] = useState<string>("");
@ -17,10 +19,10 @@ export default function WorkspaceInviteSection() {
<>
<div>
<Text fw={500} mb="sm">
Invite link
{t("Invite link")}
</Text>
<Text c="dimmed" mb="sm">
Anyone with this link can join this workspace.
{t("Anyone with this link can join this workspace.")}
</Text>
</div>
@ -31,7 +33,7 @@ export default function WorkspaceInviteSection() {
<CopyButton value={inviteLink}>
{({ copied, copy }) => (
<Button color={copied ? "teal" : ""} onClick={copy}>
{copied ? "Copied" : "Copy"}
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>

View File

@ -6,17 +6,21 @@ import InviteActionMenu from "@/features/workspace/components/members/components
import {IconInfoCircle} from "@tabler/icons-react";
import {formattedDate, timeAgo} from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function WorkspaceInvitesTable() {
const {data, isLoading} = useWorkspaceInvitationsQuery({
const { t } = useTranslation();
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
const {isAdmin} = useUserRole();
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle/>}>
Invited members who are yet to accept their invitation will appear here.
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
{t(
"Invited members who are yet to accept their invitation will appear here.",
)}
</Alert>
{data && (
@ -25,9 +29,9 @@ export default function WorkspaceInvitesTable() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>{t("Email")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -45,7 +49,7 @@ export default function WorkspaceInvitesTable() {
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{t(getUserRoleLabel(invitation.role))}</Table.Td>
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>

View File

@ -11,14 +11,18 @@ import {
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
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() {
const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const { t } = useTranslation();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
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 (
userId: string,
@ -44,9 +48,9 @@ export default function WorkspaceMembersTable() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -66,11 +70,9 @@ export default function WorkspaceMembersTable() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}

Some files were not shown because too many files have changed in this diff Show More