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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
import { Group, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountLanguage() {
const { t } = useTranslation("settings", {
keyPrefix: "preference",
});
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Language")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred interface language.")}
</Text>
</div>
<LanguageSwitcher />
</Group>
);
}
function LanguageSwitcher() {
const { t, i18n } = useTranslation("settings", {
keyPrefix: "preference",
});
const handleChange = (value: string) => {
i18n.changeLanguage(value);
};
return (
<Select
label={t("Select language")}
data={[
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]}
value={i18n.language}
onChange={handleChange}
allowDeselect={false}
checkIconPosition="right"
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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