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` - 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` - 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 - 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", "date-fns": "^3.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.9.3", "jotai": "^2.9.3",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -41,6 +44,7 @@
"react-drawio": "^0.2.0", "react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-moveable": "^0.56.0", "react-moveable": "^0.56.0",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5", "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 { useAtom, useAtomValue } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.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 { io } from "socket.io-client";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
import { SOCKET_URL } from "@/features/websocket/types"; 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 Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx"; import InviteSignup from "@/pages/auth/invite-signup.tsx";
import { useTranslation } from "react-i18next";
export default function App() { export default function App() {
const [, setSocket] = useAtom(socketAtom); const [, setSocket] = useAtom(socketAtom);
const authToken = useAtomValue(authTokensAtom); const authToken = useAtomValue(authTokensAtom);
const { t } = useTranslation("translation", {
keyPrefix: "common",
});
useEffect(() => { useEffect(() => {
if (!authToken?.accessToken) { if (!authToken?.accessToken) {
@ -74,7 +78,7 @@ export default function App() {
path={"/s/:spaceSlug/p/:pageSlug"} path={"/s/:spaceSlug/p/:pageSlug"}
element={ element={
<ErrorBoundary <ErrorBoundary
fallback={<>Failed to load page. An error occurred.</>} fallback={<>{t("Failed to load page. An error occurred.")}</>}
> >
<Page /> <Page />
</ErrorBoundary> </ErrorBoundary>

View File

@ -13,11 +13,14 @@ import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
} }
export default function RecentChanges({ spaceId }: Props) { export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation("translation", { keyPrefix: "common" });
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId); const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
if (isLoading) { if (isLoading) {
@ -25,7 +28,7 @@ export default function RecentChanges({ spaceId }: Props) {
} }
if (isError) { if (isError) {
return <Text>Failed to fetch recent pages</Text>; return <Text>{t("Failed to fetch recent pages")}</Text>;
} }
return pages && pages.items.length > 0 ? ( return pages && pages.items.length > 0 ? (
@ -43,7 +46,7 @@ export default function RecentChanges({ spaceId }: Props) {
{page.icon || <IconFileDescription size={18} />} {page.icon || <IconFileDescription size={18} />}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"} {page.title || t("Untitled")}
</Text> </Text>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@ -73,7 +76,7 @@ export default function RecentChanges({ spaceId }: Props) {
</ScrollArea> </ScrollArea>
) : ( ) : (
<Text size="md" ta="center"> <Text size="md" ta="center">
No pages yet {t("No pages yet")}
</Text> </Text>
); );
} }

View File

@ -11,10 +11,14 @@ import {
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
export function AppHeader() { export function AppHeader() {
const { t } = useTranslation("translation", {
keyPrefix: "layout",
});
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom); const toggleMobile = useToggleSidebar(mobileSidebarAtom);
@ -25,7 +29,7 @@ export function AppHeader() {
const items = links.map((link) => ( const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}> <Link key={link.label} to={link.link} className={classes.link}>
{link.label} {t(link.label)}
</Link> </Link>
)); ));

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@ -25,6 +26,7 @@ const formSchema = z.object({
export function LoginForm() { export function LoginForm() {
const { signIn, isLoading } = useAuth(); const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated(); useRedirectIfAuthenticated();
const { t } = useTranslation("login");
const form = useForm<ILogin>({ const form = useForm<ILogin>({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
@ -42,28 +44,28 @@ export function LoginForm() {
<Container size={420} my={40} className={classes.container}> <Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}> <Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
Login {t("Login")}
</Title> </Title>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
label="Email" label={t("Email")}
placeholder="email@example.com" placeholder="email@example.com"
variant="filled" variant="filled"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
label="Password" label={t("Password")}
placeholder="Your password" placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In {t("Sign In")}
</Button> </Button>
</form> </form>
</Box> </Box>

View File

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

View File

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

View File

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

View File

@ -1,15 +1,19 @@
import { Button, Divider, Modal } from "@mantine/core"; import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx"; import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateGroupModal() { export default function CreateGroupModal() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
return ( return (
<> <>
<Button onClick={open}>Create group</Button> <Button onClick={open}>{t("Create group")}</Button>
<Modal opened={opened} onClose={close} title="Create group"> <Modal opened={opened} onClose={close} title={t("Create group")}>
<Divider size="xs" mb="xs" /> <Divider size="xs" mb="xs" />
<CreateGroupForm /> <CreateGroupForm />
</Modal> </Modal>

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,12 @@ import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const { data, isLoading } = useGetGroupsQuery(); const { data, isLoading } = useGetGroupsQuery();
return ( return (
@ -13,8 +17,8 @@ export default function GroupList() {
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Group</Table.Th> <Table.Th>{t("Group")}</Table.Th>
<Table.Th>Members</Table.Th> <Table.Th>{t("Members")}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>

View File

@ -9,8 +9,12 @@ import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.group",
});
const { groupId } = useParams(); const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId); const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
@ -26,15 +30,16 @@ export default function GroupMembersList() {
const openRemoveModal = (userId: string) => const openRemoveModal = (userId: string) =>
modals.openConfirmModal({ modals.openConfirmModal({
title: "Remove group member", title: t("Remove group member"),
children: ( children: (
<Text size="sm"> <Text size="sm">
Are you sure you want to remove this user from the group? The user {t(
will lose access to resources this group has access to. "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
)}
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Delete", cancel: "Cancel" }, labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: () => onRemove(userId), onConfirm: () => onRemove(userId),
}); });
@ -45,8 +50,8 @@ export default function GroupMembersList() {
<Table verticalSpacing="sm"> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>User</Table.Th> <Table.Th>{t("User")}</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@ -69,7 +74,7 @@ export default function GroupMembersList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="light">Active</Badge> <Badge variant="light">{t("Active")}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
@ -90,7 +95,7 @@ export default function GroupMembersList() {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}> <Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member {t("Remove group member")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -1,15 +1,19 @@
import { Button, Divider, Modal } from "@mantine/core"; import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx"; import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateSpaceModal() { export default function CreateSpaceModal() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
return ( return (
<> <>
<Button onClick={open}>Create space</Button> <Button onClick={open}>{t("Create space")}</Button>
<Modal opened={opened} onClose={close} title="Create space"> <Modal opened={opened} onClose={close} title={t("Create space")}>
<Divider size="xs" mb="xs" /> <Divider size="xs" mb="xs" />
<CreateSpaceForm /> <CreateSpaceForm />
</Modal> </Modal>

View File

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

View File

@ -6,6 +6,7 @@ import { useSearchSuggestionsQuery } from "@/features/search/queries/search-quer
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps { interface MultiMemberSelectProps {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
@ -30,6 +31,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
); );
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) { export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500); const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
@ -103,8 +107,8 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
renderOption={renderMultiSelectOption} renderOption={renderMultiSelectOption}
hidePickedOptions hidePickedOptions
maxDropdownHeight={300} maxDropdownHeight={300}
label="Add members" label={t("Add members")}
placeholder="Search for users and groups" placeholder={t("Search for users and groups")}
searchable searchable
searchValue={searchValue} searchValue={searchValue}
onSearchChange={setSearchValue} onSearchChange={setSearchValue}

View File

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

View File

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

View File

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

View File

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

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 SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data, isLoading } = useGetSpacesQuery(); const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@ -21,8 +25,8 @@ export default function SpaceList() {
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Space</Table.Th> <Table.Th>{t("Space")}</Table.Th>
<Table.Th>Members</Table.Th> <Table.Th>{t("Members")}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>

View File

@ -16,6 +16,7 @@ import {
spaceRoleData, spaceRoleData,
} from "@/features/space/types/space-role-data.ts"; } from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
interface SpaceMembersProps { interface SpaceMembersProps {
@ -26,6 +27,9 @@ export default function SpaceMembersList({
spaceId, spaceId,
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.space",
});
const { data, isLoading } = useSpaceMembersQuery(spaceId); const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation(); const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation(); const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -77,15 +81,16 @@ export default function SpaceMembersList({
const openRemoveModal = (memberId: string, type: MemberType) => const openRemoveModal = (memberId: string, type: MemberType) =>
modals.openConfirmModal({ modals.openConfirmModal({
title: "Remove space member", title: t("Remove space member"),
children: ( children: (
<Text size="sm"> <Text size="sm">
Are you sure you want to remove this user from the space? The user {t(
will lose all access to this space. "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
)}
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Remove", cancel: "Cancel" }, labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type), onConfirm: () => onRemove(memberId, type),
}); });
@ -96,8 +101,8 @@ export default function SpaceMembersList({
<Table verticalSpacing={8}> <Table verticalSpacing={8}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Member</Table.Th> <Table.Th>{t("Member")}</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>{t("Role")}</Table.Th>
<Table.Th></Table.Th> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@ -168,7 +173,7 @@ export default function SpaceMembersList({
openRemoveModal(member.id, member.type) openRemoveModal(member.id, member.type)
} }
> >
Remove space member {t("Remove space member")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,13 +12,19 @@ import {
} from "@/features/workspace/types/user-role-data.ts"; } from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts"; import { UserRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.member",
});
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 }); const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation(); const changeMemberRoleMutation = useChangeMemberRoleMutation();
const { isAdmin, isOwner } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER); const assignableUserRoles = isOwner
? userRoleData
: userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async ( const handleRoleChange = async (
userId: string, userId: string,
@ -43,9 +49,9 @@ export default function WorkspaceMembersTable() {
<Table verticalSpacing="sm"> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>User</Table.Th> <Table.Th>{t("User")}</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>{t("Status")}</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>{t("Role")}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@ -67,7 +73,7 @@ export default function WorkspaceMembersTable() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="light">Active</Badge> <Badge variant="light">{t("Active")}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>

View File

@ -9,6 +9,7 @@ import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(4).nonempty("Workspace name cannot be blank"), name: z.string().min(4).nonempty("Workspace name cannot be blank"),
@ -21,6 +22,9 @@ const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
); );
export default function WorkspaceNameForm() { export default function WorkspaceNameForm() {
const { t } = useTranslation("settings", {
keyPrefix: "workspace.general",
});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom); const [, setWorkspace] = useAtom(workspaceAtom);
@ -39,11 +43,11 @@ export default function WorkspaceNameForm() {
try { try {
const updatedWorkspace = await updateWorkspace(data); const updatedWorkspace = await updateWorkspace(data);
setWorkspace(updatedWorkspace); setWorkspace(updatedWorkspace);
notifications.show({ message: "Updated successfully" }); notifications.show({ message: t("Updated successfully") });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
notifications.show({ notifications.show({
message: "Failed to update data", message: t("Failed to update data"),
color: "red", color: "red",
}); });
} }
@ -55,8 +59,8 @@ export default function WorkspaceNameForm() {
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput <TextInput
id="name" id="name"
label="Name" label={t("Name")}
placeholder="e.g ACME" placeholder={t("e.g ACME")}
variant="filled" variant="filled"
readOnly={!isAdmin} readOnly={!isAdmin}
{...form.getInputProps("name")} {...form.getInputProps("name")}
@ -69,7 +73,7 @@ export default function WorkspaceNameForm() {
disabled={isLoading || !form.isDirty()} disabled={isLoading || !form.isDirty()}
loading={isLoading} loading={isLoading}
> >
Save {t("Save")}
</Button> </Button>
)} )}
</form> </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/core/styles.css";
import "@mantine/spotlight/styles.css"; import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { theme } from "@/theme"; import { theme } from "@/theme";
@ -11,6 +10,7 @@ import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "react-helmet-async";
import "./i18n";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -24,7 +24,7 @@ export const queryClient = new QueryClient({
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement, document.getElementById("root") as HTMLElement
); );
root.render( root.render(
@ -39,5 +39,5 @@ root.render(
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</BrowserRouter>, </BrowserRouter>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18351
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff