feat: support i18n

This commit is contained in:
lleohao
2024-08-30 10:05:03 +08:00
parent 8af2d4e8cf
commit cd1a848b45
74 changed files with 12842 additions and 6775 deletions

View File

@ -25,3 +25,9 @@ If you are developing a production application, we recommend updating the config
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
## Add i18n namespaces
`node scripts/i18n-tools.js add-ns <namespcaes>`
For example `node scripts/i18n-tools.js add-ns login`

View File

@ -27,6 +27,9 @@
"date-fns": "^3.6.0",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.9.3",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
@ -41,6 +44,7 @@
"react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-moveable": "^0.56.0",
"react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5",

View File

@ -0,0 +1,11 @@
{
"Join the workspace": "Join the workspace",
"Name": "Name",
"enter your full name": "enter your full name",
"Email": "Email",
"Password": "Password",
"Your password": "Your password",
"Sign Up": "Sign Up",
"invalid invitation link": "invalid invitation link",
"Invitation signup": "Invitation signup"
}

View File

@ -0,0 +1,7 @@
{
"Login": "Login",
"Email": "Email",
"Password": "Password",
"Your password": "Your password",
"Sign In": "Sign In"
}

View File

@ -0,0 +1,138 @@
{
"account": {
"My Profile": "My Profile",
"Change photo": "Change photo",
"Name": "Name",
"Your name": "Your name",
"Save": "Save",
"Updated successfully": "Updated successfully",
"Failed to update data": "Failed to update data",
"Email": "Email",
"Change email": "Change email",
"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.",
"Password": "Password",
"Enter your password": "Enter your password",
"Enter your new preferred email": "Enter your new preferred email",
"New email": "New email",
"You can change your password here.": "You can change your password here.",
"Change password": "Change password",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Current password": "Current password",
"Enter your current password": "Enter your current password",
"New password": "New password",
"Enter your new password": "Enter your new password",
"Password changed successfully": "Password changed successfully"
},
"preference": {
"Preferences": "Preferences",
"Theme": "Theme",
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Select theme": "Select theme",
"Light": "Light",
"Dark": "Dark",
"System settings": "System settings",
"Language": "Language",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Select language": "Select language",
"Full page width": "Full page width",
"Choose your preferred page width.": "Choose your preferred page width.",
"Toggle full page width": "Toggle full page width"
},
"workspace": {
"general": {
"General": "General",
"Name": "Name",
"e.g ACME": "e.g ACME",
"Save": "Save",
"Updated successfully": "Updated successfully",
"Failed to update data": "Failed to update data"
},
"member": {
"Members": "Members",
"Invite members": "Invite members",
"Invite new members": "Invite new members",
"Pending": "Pending",
"User": "User",
"Status": "Status",
"Role": "Role",
"Active": "Active",
"Email": "Email",
"Date": "Date",
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
"Invite by email": "Invite by email",
"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",
"Select role": "Select role",
"Select role to assign to all invited members": "Select role to assign to all invited members",
"Choose a role": "Choose a role",
"Add to groups": "Add to groups",
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
"Send invitation": "Send invitation"
},
"group": {
"Groups": "Groups",
"Create group": "Create group",
"Group": "Group",
"Members": "Members",
"member": "member",
"members": "members",
"Manage Group": "Manage Group",
"addGroupMembers": "addGroupMembers",
"add": "add",
"Edit group": "Edit group",
"Group name": "Group name",
"e.g Developers": "e.g Developers",
"Group description": "Group description",
"e.g Group for developers": "e.g Group for developers",
"Edit": "Edit",
"Delete group": "Delete group",
"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.",
"Delete": "Delete",
"Cancel": "Cancel",
"Remove group member": "Remove group member",
"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.",
"Add group members": "Add group members",
"Create": "Create",
"User": "User",
"Status": "Status",
"Active": "Active",
"Add members": "Add members",
"Search for users": "Search for users",
"No user found": "No user found"
},
"space": {
"Spaces": "Spaces",
"Create space": "Create space",
"Space": "Space",
"Members": "Members",
"Settings": "Settings",
"Details": "Details",
"Name": "Name",
"e.g Sales": "e.g Sales",
"Slug": "Slug",
"Description": "Description",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Save": "Save",
"Space name": "Space name",
"e.g Product Team": "e.g Product Team",
"Space slug": "Space slug",
"e.g product": "e.g product",
"Space description": "Space description",
"e.g Space for product team": "e.g Space for product team",
"Create": "Create",
"addSpaceMembers": "addSpaceMembers",
"add": "add",
"selectRole": "selectRole",
"Remove space member": "Remove space member",
"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.",
"Remove": "Remove",
"Cancel": "Cancel",
"Member": "Member",
"Role": "Role",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Add members": "Add members",
"Search for users and groups": "Search for users and groups",
"No user found": "No user found"
}
}
}

View File

@ -0,0 +1,11 @@
{
"Setup workspace": "Setup workspace",
"Create workspace": "Create workspace",
"Workspace Name": "Workspace Name",
"e.g ACME Inc": "e.g ACME Inc",
"Your Name": "Your Name",
"enter your full name": "enter your full name",
"Your Email": "Your Email",
"Password": "Password",
"Enter a strong password": "Enter a strong password"
}

View File

@ -0,0 +1,55 @@
{
"common": {
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Untitled": "Untitled",
"No pages yet": "No pages yet",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred."
},
"layout": {
"Home": "Home",
"Workspace": "Workspace",
"Workspace settings": "Workspace settings",
"Manage members": "Manage members",
"Account": "Account",
"My profile": "My profile",
"My preferences": "My preferences",
"Logout": "Logout",
"Settings": "Settings",
"Profile": "Profile",
"Preferences": "Preferences",
"General": "General",
"Members": "Members",
"Groups": "Groups",
"Spaces": "Spaces"
},
"home": {
"Recently updated": "Recently updated"
},
"space": {
"Spaces you belong to": "Spaces you belong to",
"Recently updated": "Recently updated"
},
"page": {
"Error fetching page data.": "Error fetching page data.",
"untitled": "untitled",
"Link copied": "Link copied",
"Copy link": "Copy link",
"Full width": "Full width",
"Page history": "Page history",
"Export": "Export",
"Print PDF": "Print PDF",
"Delete": "Delete"
},
"page-history": {
"Page history": "Page history",
"Error loading page history.": "Error loading page history.",
"No page history saved yet.": "No page history saved yet.",
"Please confirm your action": "Please confirm your action",
"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.",
"Confirm": "Confirm",
"Cancel": "Cancel",
"Successfully restored": "Successfully restored",
"Restore": "Restore",
"Error fetching page data.": "Error fetching page data."
}
}

View File

@ -0,0 +1,11 @@
{
"Join the workspace": "加入工作空间",
"Name": "姓名",
"enter your full name": "输入您的全名",
"Email": "电子邮箱",
"Password": "密码",
"Your password": "您的密码",
"Sign Up": "注册",
"invalid invitation link": "无效的邀请链接",
"Invitation signup": "邀请注册"
}

View File

@ -0,0 +1,7 @@
{
"Login": "登录",
"Email": "电子邮箱",
"Password": "密码",
"Your password": "您的密码",
"Sign In": "登录"
}

View File

@ -0,0 +1,138 @@
{
"account": {
"My Profile": "我的个人资料",
"Change photo": "更改照片",
"Name": "姓名",
"Your name": "您的姓名",
"Save": "保存",
"Updated successfully": "更新成功",
"Failed to update data": "数据更新失败",
"Email": "电子邮箱",
"Change email": "更改电子邮箱",
"To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。",
"Password": "密码",
"Enter your password": "输入您的密码",
"Enter your new preferred email": "输入您新的首选电子邮箱",
"New email": "新电子邮箱",
"You can change your password here.": "您可以在这里更改密码。",
"Change password": "更改密码",
"Your password must be a minimum of 8 characters.": "您的密码必须至少包含8个字符。",
"Current password": "当前密码",
"Enter your current password": "输入您的当前密码",
"New password": "新密码",
"Enter your new password": "输入您的新密码",
"Password changed successfully": "密码更改成功"
},
"preference": {
"Preferences": "偏好设置",
"Theme": "主题",
"Choose your preferred color scheme.": "选择您喜欢的配色方案。",
"Select theme": "选择主题",
"Light": "浅色",
"Dark": "深色",
"System settings": "系统设置",
"Language": "语言",
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Select language": "选择语言",
"Full page width": "全页宽度",
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Toggle full page width": "切换全页宽度"
},
"workspace": {
"general": {
"General": "常规",
"Name": "名称",
"e.g ACME": "例如ACME",
"Save": "保存",
"Updated successfully": "更新成功",
"Failed to update data": "数据更新失败"
},
"member": {
"Members": "成员",
"Invite members": "邀请成员",
"Invite new members": "邀请新成员",
"Pending": "待定",
"User": "用户",
"Status": "状态",
"Role": "角色",
"Active": "活跃",
"Email": "电子邮箱",
"Date": "日期",
"Invited members who are yet to accept their invitation will appear here.": "尚未接受邀请的成员将显示在这里。",
"Invite by email": "通过电子邮箱邀请",
"Enter valid email addresses separated by comma or space max_50": "输入有效的电子邮箱地址,用逗号或空格分隔 [最多50个]",
"enter valid emails addresses": "输入有效的电子邮箱地址",
"Select role": "选择角色",
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
"Choose a role": "选择一个角色",
"Add to groups": "添加到群组",
"Invited members will be granted access to spaces the groups can access": "被邀请的成员将被授予访问群组可以访问的空间的权限",
"Send invitation": "发送邀请"
},
"group": {
"Groups": "群组",
"Create group": "创建群组",
"Group": "群组",
"Members": "成员",
"member": "成员",
"members": "成员",
"Manage Group": "管理群组",
"addGroupMembers": "添加群组成员",
"add": "添加",
"Edit group": "编辑群组",
"Group name": "群组名称",
"e.g Developers": "例如:开发人员",
"Group description": "群组描述",
"e.g Group for developers": "例如:开发人员群组",
"Edit": "编辑",
"Delete group": "删除群组",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "您确定要删除这个群组吗?成员将失去对该群组可访问资源的访问权限。",
"Delete": "删除",
"Cancel": "取消",
"Remove group member": "移除群组成员",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "您确定要从群组中移除这个用户吗?该用户将失去对该群组可访问资源的访问权限。",
"Add group members": "添加群组成员",
"Create": "创建",
"User": "用户",
"Status": "状态",
"Active": "活跃",
"Add members": "添加成员",
"Search for users": "搜索用户",
"No user found": "未找到用户"
},
"space": {
"Spaces": "空间",
"Create space": "创建空间",
"Space": "空间",
"Members": "成员",
"Settings": "设置",
"Details": "详情",
"Name": "名称",
"e.g Sales": "例如:销售",
"Slug": "短链接",
"Description": "描述",
"e.g Space for sales team to collaborate": "例如:销售团队协作的空间",
"Save": "保存",
"Space name": "空间名称",
"e.g Product Team": "例如:产品团队",
"Space slug": "空间短链接",
"e.g product": "例如product",
"Space description": "空间描述",
"e.g Space for product team": "例如:产品团队的空间",
"Create": "创建",
"addSpaceMembers": "添加空间成员",
"add": "添加",
"selectRole": "选择角色",
"Remove space member": "移除空间成员",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "您确定要从空间中移除这个用户吗?该用户将失去对这个空间的所有访问权限。",
"Remove": "移除",
"Cancel": "取消",
"Member": "成员",
"Role": "角色",
"Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。",
"Add members": "添加成员",
"Search for users and groups": "搜索用户和群组",
"No user found": "未找到用户"
}
}
}

View File

@ -0,0 +1,11 @@
{
"Setup workspace": "设置工作空间",
"Create workspace": "创建工作空间",
"Workspace Name": "工作空间名称",
"e.g ACME Inc": "例如ACME Inc",
"Your Name": "您的姓名",
"enter your full name": "输入您的全名",
"Your Email": "您的电子邮箱",
"Password": "密码",
"Enter a strong password": "输入一个强密码"
}

View File

@ -0,0 +1,55 @@
{
"common": {
"Failed to fetch recent pages": "获取最近页面失败",
"Untitled": "无标题",
"No pages yet": "暂无页面",
"Failed to load page. An error occurred.": "加载页面失败。发生错误。"
},
"layout": {
"Home": "首页",
"Workspace": "工作区",
"Workspace settings": "工作区设置",
"Manage members": "管理成员",
"Account": "账户",
"My profile": "我的个人资料",
"My preferences": "我的偏好设置",
"Logout": "退出登录",
"Settings": "设置",
"Profile": "个人资料",
"Preferences": "偏好设置",
"General": "常规",
"Members": "成员",
"Groups": "群组",
"Spaces": "空间"
},
"home": {
"Recently updated": "最近更新"
},
"space": {
"Spaces you belong to": "您所属的空间",
"Recently updated": "最近更新"
},
"page": {
"Error fetching page data.": "获取页面数据时出错。",
"untitled": "无标题",
"Link copied": "链接已复制",
"Copy link": "复制链接",
"Full width": "全宽",
"Page history": "页面历史",
"Export": "导出",
"Print PDF": "打印 PDF",
"Delete": "删除"
},
"page-history": {
"Page history": "页面历史",
"Error loading page history.": "加载页面历史时出错。",
"No page history saved yet.": "尚未保存页面历史。",
"Please confirm your action": "请确认您的操作",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "您确定要恢复此版本吗?任何未版本化的更改将会丢失。",
"Confirm": "确认",
"Cancel": "取消",
"Successfully restored": "恢复成功",
"Restore": "恢复",
"Error fetching page data.": "获取页面数据时出错。"
}
}

View File

@ -0,0 +1,59 @@
const fs = require("node:fs");
const path = require("node:path");
const localsPath = path.join(__dirname, "../public/locales");
const supportLanguages = ["en", "zh"];
const supportCommands = ["add-ns"];
function ensureLanguageDirectories() {
if (!fs.existsSync(localsPath)) {
fs.mkdirSync(localsPath, { recursive: true });
}
supportLanguages.forEach((lang) => {
const langPath = path.join(localsPath, lang);
if (!fs.existsSync(langPath)) {
fs.mkdirSync(langPath);
}
});
}
function addNamespaces(namespaces) {
supportLanguages.forEach((lang) => {
const langPath = path.join(localsPath, lang);
namespaces.forEach((ns) => {
const nsFilePath = path.join(langPath, `${ns}.json`);
if (!fs.existsSync(nsFilePath)) {
fs.writeFileSync(nsFilePath, "{}", "utf8");
console.log(`Created empty ${ns}.json file in ${lang} directory`);
} else {
console.log(
`${ns}.json file already exists in ${lang} directory, skipping creation`,
);
}
});
});
}
ensureLanguageDirectories();
const [command, ...params] = process.argv.slice(2);
if (!supportCommands.includes(command)) {
console.warn(
`Only support the follow commands: ${supportCommands.join(" ")} `,
);
console.log();
}
switch (command) {
case "add-ns":
addNamespaces(params);
break;
default:
console.warn("You should input a command");
}

View File

@ -14,7 +14,7 @@ import { useQuerySubscription } from "@/features/websocket/use-query-subscriptio
import { useAtom, useAtomValue } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useEffect } from "react";
import { useEffect, useTransition } from "react";
import { io } from "socket.io-client";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
import { SOCKET_URL } from "@/features/websocket/types";
@ -24,10 +24,14 @@ import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import { useTranslation } from "react-i18next";
export default function App() {
const [, setSocket] = useAtom(socketAtom);
const authToken = useAtomValue(authTokensAtom);
const { t } = useTranslation("translation", {
keyPrefix: "common",
});
useEffect(() => {
if (!authToken?.accessToken) {
@ -74,7 +78,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

@ -13,11 +13,14 @@ 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("translation", { keyPrefix: "common" });
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
if (isLoading) {
@ -25,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 ? (
@ -43,7 +46,7 @@ export default function RecentChanges({ spaceId }: Props) {
{page.icon || <IconFileDescription size={18} />}
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
@ -73,7 +76,7 @@ export default function RecentChanges({ spaceId }: Props) {
</ScrollArea>
) : (
<Text size="md" ta="center">
No pages yet
{t("No pages yet")}
</Text>
);
}

View File

@ -11,10 +11,14 @@ 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("translation", {
keyPrefix: "layout",
});
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
@ -25,7 +29,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>
));

View File

@ -13,8 +13,12 @@ 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("translation", {
keyPrefix: "layout",
});
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
@ -44,14 +48,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 +63,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 +92,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 +100,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,9 @@ const groupedData: DataGroup[] = [
];
export default function SettingsSidebar() {
const { t } = useTranslation("translation", {
keyPrefix: "layout",
});
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
@ -62,7 +66,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 +76,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 +93,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

@ -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().min(2),
@ -26,6 +27,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function InviteSignUpForm() {
const { t } = useTranslation("invite-signup");
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

@ -13,6 +13,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({
email: z
@ -25,6 +26,7 @@ const formSchema = z.object({
export function LoginForm() {
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const { t } = useTranslation("login");
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
@ -42,28 +44,28 @@ 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>
</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().min(2).max(60),
@ -28,6 +29,7 @@ export function SetupWorkspaceForm() {
const { setupWorkspace, isLoading } = useAuth();
// useRedirectIfAuthenticated();
const { t } = useTranslation("setup-workspace");
const form = useForm<ISetupWorkspace>({
validate: zodResolver(formSchema),
initialValues: {
@ -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

@ -4,8 +4,12 @@ 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("settings", {
keyPrefix: "workspace.group",
});
const { groupId } = useParams();
const [opened, { open, close }] = useDisclosure(false);
const [userIds, setUserIds] = useState<string[]>([]);
@ -27,19 +31,19 @@ export default function AddGroupMemberModal() {
return (
<>
<Button onClick={open}>Add group members</Button>
<Button onClick={open}>{t("addGroupMembers")}</Button>
<Modal opened={opened} onClose={close} title="Add group members">
<Modal opened={opened} onClose={close} title={t("addGroupMembers")}>
<Divider size="xs" mb="xs" />
<MultiUserSelect
label={"Add group members"}
label={t("addGroupMembers")}
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().min(2).max(50),
@ -14,6 +15,9 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const createGroupMutation = useCreateGroupMutation();
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
@ -52,16 +56,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 +74,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,19 @@
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("settings", {
keyPrefix: "workspace.group",
});
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,9 @@ interface EditGroupFormProps {
onClose?: () => void;
}
export function EditGroupForm({ onClose }: EditGroupFormProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const updateGroupMutation = useUpdateGroupMutation();
const { isSuccess } = updateGroupMutation;
const { groupId } = useParams();
@ -60,16 +64,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 +83,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Edit</Button>
<Button type="submit">{t("Edit")}</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,13 @@ export default function EditGroupModal({
opened,
onClose,
}: EditGroupModalProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
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,12 @@ 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("settings", {
keyPrefix: "workspace.group",
});
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const deleteGroupMutation = useDeleteGroupMutation();
@ -24,15 +28,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 +62,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 +71,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

@ -3,8 +3,12 @@ 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 { useTranslation } from "react-i18next";
export default function GroupList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const { data, isLoading } = useGetGroupsQuery();
return (
@ -13,8 +17,8 @@ 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>

View File

@ -9,8 +9,12 @@ 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";
export default function GroupMembersList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
@ -26,15 +30,16 @@ 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" },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
@ -45,8 +50,8 @@ 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>
@ -69,7 +74,7 @@ export default function GroupMembersList() {
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
@ -90,7 +95,7 @@ export default function GroupMembersList() {
<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 { 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,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
@ -65,15 +69,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("translation", { keyPrefix: "home" });
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,15 @@ 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("translation", { keyPrefix: "pageHistory" });
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const {
data: pageHistoryList,
@ -36,14 +39,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 +64,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 +83,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 +108,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("translation", { keyPrefix: "pageHistory" });
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("translation", { keyPrefix: "pageHistory" });
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 &&
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";
interface PageHeaderMenuProps {
readOnly?: boolean;
@ -52,6 +53,8 @@ interface PageActionMenuProps {
readOnly?: boolean;
}
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { t } = useTranslation("translation", { keyPrefix: "page" });
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { pageSlug, spaceSlug } = useParams();
@ -68,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 = () => {
@ -106,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>
@ -120,7 +123,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
Page history
{t("Page history")}
</Menu.Item>
<Menu.Divider />
@ -129,14 +132,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconDownload 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 && (
@ -147,7 +150,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
Delete
{t("Delete")}
</Menu.Item>
</>
)}

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,9 @@ interface AddSpaceMemberModalProps {
export default function AddSpaceMembersModal({
spaceId,
}: AddSpaceMemberModalProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const [opened, { open, close }] = useDisclosure(false);
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(SpaceRole.WRITER);
@ -48,8 +52,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("addSpaceMembers")}</Button>
<Modal opened={opened} onClose={close} title={t("addSpaceMembers")}>
<Divider size="xs" mb="xs" />
<Stack>
@ -57,13 +61,13 @@ export default function AddSpaceMembersModal({
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
label="Select role"
label={t("selectRole")}
/>
</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().min(2).max(50),
@ -22,6 +23,9 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateSpaceForm() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const createSpaceMutation = useCreateSpaceMutation();
const navigate = useNavigate();
@ -73,8 +77,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")}
/>
@ -82,16 +86,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}
@ -101,7 +105,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,19 @@
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("settings", {
keyPrefix: "workspace.space",
});
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),
@ -16,6 +17,9 @@ interface EditSpaceFormProps {
readOnly?: boolean;
}
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
@ -51,8 +55,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")}
@ -60,7 +64,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<TextInput
id="slug"
label="Slug"
label={t("Slug")}
variant="filled"
readOnly
value={space.slug}
@ -68,8 +72,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
@ -82,7 +86,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,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
@ -103,8 +107,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;
@ -21,6 +22,9 @@ export default function SpaceSettingsModal({
opened,
onClose,
}: SpaceSettingsModalProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data: space, isLoading } = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions;
@ -48,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

@ -2,12 +2,16 @@ import React from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface SpaceDetailsProps {
spaceId: string;
readOnly?: boolean;
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
@ -15,7 +19,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{space && (
<div>
<Text my="md" fw={600}>
Details
{t("Details")}
</Text>
<EditSpaceForm space={space} readOnly={readOnly} />
</div>

View File

@ -5,8 +5,11 @@ 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("translation", { keyPrefix: "space" });
const { data, isLoading } = useGetSpacesQuery();
const cards = data?.items.map((space, index) => (
@ -41,7 +44,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,12 @@ 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("translaction", {
keyPrefix: "workspace.space",
});
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@ -13,7 +17,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

@ -4,8 +4,12 @@ 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 { useTranslation } from "react-i18next";
export default function SpaceList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@ -21,8 +25,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>

View File

@ -16,6 +16,7 @@ import {
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
type MemberType = "user" | "group";
interface SpaceMembersProps {
@ -26,6 +27,9 @@ export default function SpaceMembersList({
spaceId,
readOnly,
}: SpaceMembersProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -77,15 +81,16 @@ 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" },
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type),
});
@ -96,8 +101,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>
@ -168,7 +173,7 @@ export default function SpaceMembersList({
openRemoveModal(member.id, member.type)
}
>
Remove space member
{t("Remove space member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -5,10 +5,14 @@ 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("settings", {
keyPrefix: "account",
});
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,7 +40,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,44 @@
import { Group, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountLanguage() {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
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("settings", {
keyPrefix: "preference",
});
const handleChange = (value: string) => {
i18n.changeLanguage(value);
};
return (
<Select
label={t("Select language")}
data={[
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]}
value={i18n.language}
onChange={handleChange}
allowDeselect={false}
checkIconPosition="right"
/>
);
}

View File

@ -8,6 +8,7 @@ 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"),
@ -18,6 +19,9 @@ type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() {
const { t } = useTranslation("settings", {
keyPrefix: "account",
});
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,12 +40,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 +57,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,19 @@ import {
Select,
MantineColorScheme,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountTheme() {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
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 +27,10 @@ export default function AccountTheme() {
}
function ThemeSwitcher() {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
const { colorScheme, setColorScheme } = useMantineColorScheme();
const handleChange = (value: MantineColorScheme) => {
@ -30,11 +39,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,19 @@ 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("settings", {
keyPrefix: "account",
});
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 +33,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 +59,9 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
function ChangeEmailForm() {
const { t } = useTranslation("settings", {
keyPrefix: "account",
});
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -71,8 +80,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 +89,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,36 @@ 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("settings", {
keyPrefix: "account",
});
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 +55,9 @@ interface ChangePasswordFormProps {
onClose?: () => void;
}
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
const { t } = useTranslation("settings", {
keyPrefix: "account",
});
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -62,7 +76,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
newPassword: data.newPassword,
});
notifications.show({
message: "Password changed successfully",
message: t("Password changed successfully"),
});
onClose();
@ -78,9 +92,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 +102,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 +111,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,19 @@ 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("settings", {
keyPrefix: "preference",
});
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 +29,9 @@ interface PageWidthToggleProps {
label?: string;
}
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.preferences?.fullPageWidth,
@ -43,7 +51,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

@ -5,11 +5,15 @@ 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("settings", {
keyPrefix: "workspace.member",
});
const [emails, setEmails] = useState<string[]>([]);
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
const [groupIds, setGroupIds] = useState<string[]>([]);
@ -44,9 +48,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,9 +62,9 @@ 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)}
defaultValue={UserRole.MEMBER}
@ -69,8 +75,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 +87,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
onClick={handleSubmit}
loading={createInvitationMutation.isPending}
>
Send invitation
{t("Send invitation")}
</Button>
</Group>
</Box>

View File

@ -1,19 +1,23 @@
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("settings", {
keyPrefix: "workspace.member",
});
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

@ -6,8 +6,12 @@ import InviteActionMenu from "@/features/workspace/components/members/components
import { IconInfoCircle } from "@tabler/icons-react";
import { formattedDate } from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function WorkspaceInvitesTable() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.member",
});
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
@ -16,7 +20,9 @@ export default function WorkspaceInvitesTable() {
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
Invited members who are yet to accept their invitation will appear here.
{t(
"Invited members who are yet to accept their invitation will appear here.",
)}
</Alert>
{data && (
@ -24,9 +30,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>

View File

@ -12,13 +12,19 @@ import {
} from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export default function WorkspaceMembersTable() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.member",
});
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,
@ -43,9 +49,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>
@ -67,7 +73,7 @@ export default function WorkspaceMembersTable() {
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>

View File

@ -9,6 +9,7 @@ import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(4).nonempty("Workspace name cannot be blank"),
@ -21,6 +22,9 @@ const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
);
export default function WorkspaceNameForm() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.general",
});
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom);
@ -39,11 +43,11 @@ export default function WorkspaceNameForm() {
try {
const updatedWorkspace = await updateWorkspace(data);
setWorkspace(updatedWorkspace);
notifications.show({ message: "Updated successfully" });
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.log(err);
notifications.show({
message: "Failed to update data",
message: t("Failed to update data"),
color: "red",
});
}
@ -55,8 +59,8 @@ export default function WorkspaceNameForm() {
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="name"
label="Name"
placeholder="e.g ACME"
label={t("Name")}
placeholder={t("e.g ACME")}
variant="filled"
readOnly={!isAdmin}
{...form.getInputProps("name")}
@ -69,7 +73,7 @@ export default function WorkspaceNameForm() {
disabled={isLoading || !form.isDirty()}
loading={isLoading}
>
Save
{t("Save")}
</Button>
)}
</form>

34
apps/client/src/i18n.ts Normal file
View File

@ -0,0 +1,34 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
// don't want to use this?
// have a look at the Quick start guide
// for passing in lng and translations on init
i18n
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
// learn more: https://github.com/i18next/i18next-http-backend
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: "en",
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
react: {
useSuspense: false,
}
});
export default i18n;

View File

@ -1,7 +1,6 @@
import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { theme } from "@/theme";
@ -11,6 +10,7 @@ import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
export const queryClient = new QueryClient({
defaultOptions: {
@ -24,7 +24,7 @@ export const queryClient = new QueryClient({
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
document.getElementById("root") as HTMLElement
);
root.render(
@ -39,5 +39,5 @@ root.render(
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
</BrowserRouter>
);

View File

@ -1,11 +1,14 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import { useTranslation } from "react-i18next";
export default function InviteSignup() {
const { t } = useTranslation("invite-signup");
return (
<>
<Helmet>
<title>Invitation signup</title>
<title>{t("Invitation signup")}</title>
</Helmet>
<InviteSignUpForm />
</>

View File

@ -1,11 +1,14 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
const { t } = useTranslation("login");
return (
<>
<Helmet>
<title>Login</title>
<title>{t("Login")}</title>
</Helmet>
<LoginForm />
</>

View File

@ -3,8 +3,10 @@ import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-f
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function SetupWorkspace() {
const { t } = useTranslation("setup-workspace");
const {
data: workspace,
isLoading,
@ -32,7 +34,7 @@ export default function SetupWorkspace() {
return (
<>
<Helmet>
<title>Setup workspace</title>
<title>{t("Setup workspace")}</title>
</Helmet>
<SetupWorkspaceForm />
</>

View File

@ -12,8 +12,10 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
export default function Page() {
const { t } = useTranslation("translation", { keyPrefix: "page" });
const { pageSlug } = useParams();
const {
data: page,
@ -31,7 +33,7 @@ export default function Page() {
if (isError || !page) {
// TODO: fix this
return <div>Error fetching page data.</div>;
return <div>{t("Error fetching page data.")}</div>;
}
if (!space) {
@ -42,7 +44,7 @@ export default function Page() {
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${page?.title || "untitled"}`}</title>
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<PageHeader

View File

@ -1,14 +1,22 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-languate";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import { Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountPreferences() {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
return (
<>
<SettingsTitle title="Preferences" />
<SettingsTitle title={t("Preferences")} />
<AccountTheme />
<Divider my={"md"} />
<AccountLanguage />
<Divider my={"md"} />
<PageWidthPref />
</>
);

View File

@ -4,11 +4,16 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { useTranslation } from "react-i18next";
export default function AccountSettings() {
const { t } = useTranslation("settings", {
keyPrefix: "account",
});
return (
<>
<SettingsTitle title="My Profile" />
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />

View File

@ -1,11 +1,16 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import { useTranslation } from "react-i18next";
export default function GroupInfo() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
return (
<>
<SettingsTitle title="Manage Group" />
<SettingsTitle title={t("Manage Group")} />
<GroupDetails />
<GroupMembersList />
</>

View File

@ -3,13 +3,17 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function Groups() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const { isAdmin } = useUserRole();
return (
<>
<SettingsTitle title="Groups" />
<SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end">
{isAdmin && <CreateGroupModal />}

View File

@ -3,13 +3,17 @@ import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import { useTranslation } from "react-i18next";
export default function Spaces() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { isAdmin } = useUserRole();
return (
<>
<SettingsTitle title="Spaces" />
<SettingsTitle title={t("Spaces")} />
<Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />}

View File

@ -6,8 +6,12 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function WorkspaceMembers() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.member",
});
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
const { isAdmin } = useUserRole();
@ -31,7 +35,7 @@ export default function WorkspaceMembers() {
return (
<>
<SettingsTitle title="Members" />
<SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
@ -41,8 +45,8 @@ export default function WorkspaceMembers() {
value={segmentValue}
onChange={handleSegmentChange}
data={[
{ label: "Members", value: "members" },
{ label: "Pending", value: "invites" },
{ label: t("Members"), value: "members" },
{ label: t("Pending"), value: "invites" },
]}
withItemsBorders={false}
/>

View File

@ -1,10 +1,15 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import { useTranslation } from "react-i18next";
export default function WorkspaceSettings() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.general",
});
return (
<>
<SettingsTitle title="General" />
<SettingsTitle title={t("General")} />
<WorkspaceNameForm />
</>
);

View File

@ -78,5 +78,8 @@
"apps/*",
"packages/*"
]
},
"prettier": {
"semi": true
}
}

18351
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff