Support I18n (#243)

* feat: support i18n

* feat: wip support i18n

* feat: complete space translation

* feat: complete page translation

* feat: update space translation

* feat: update workspace translation

* feat: update group translation

* feat: update workspace translation

* feat: update page translation

* feat: update user translation

* chore: update pnpm-lock

* feat: add query translation

* refactor: merge to single file

* chore: remove necessary code

* feat: save language to BE

* fix: only load current language

* feat: save language to locale column

* fix: cleanups

* add language menu to preferences page

* new translations

* translate editor

* Translate editor placeholders

* translate space selection component

---------

Co-authored-by: Philip Okugbe <phil@docmost.com>
Co-authored-by: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
lleohao
2025-01-04 21:17:17 +08:00
committed by GitHub
parent 290b7d9d94
commit 670ee64179
119 changed files with 1672 additions and 649 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,14 +11,18 @@ import {
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import {UserRole} from "@/lib/types.ts";
import { UserRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export default function WorkspaceMembersTable() {
const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const { t } = useTranslation();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const {isAdmin, isOwner} = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const assignableUserRoles = isOwner
? userRoleData
: userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async (
userId: string,
@ -44,9 +48,9 @@ export default function WorkspaceMembersTable() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -66,11 +70,9 @@ export default function WorkspaceMembersTable() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}

View File

@ -9,9 +9,10 @@ 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"),
name: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;
@ -21,6 +22,7 @@ const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
);
export default function WorkspaceNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom);
@ -39,11 +41,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 +57,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 +71,7 @@ export default function WorkspaceNameForm() {
disabled={isLoading || !form.isDirty()}
loading={isLoading}
>
Save
{t("Save")}
</Button>
)}
</form>

View File

@ -14,7 +14,7 @@ export const userRoleData: IRoleData[] = [
{
label: "Member",
value: UserRole.MEMBER,
description: "Can become members of groups and spaces in workspace.",
description: "Can become members of groups and spaces in workspace",
},
];