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

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

View File

@ -0,0 +1,53 @@
import { Group, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { updateUser } from "../services/user-service";
import { useAtom } from "jotai";
import { userAtom } from "../atoms/current-user-atom";
import { useState } from "react";
export default function AccountLanguage() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Language")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred interface language.")}
</Text>
</div>
<LanguageSwitcher />
</Group>
);
}
function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [language, setLanguage] = useState(
user?.locale === "en" ? "en-US" : user.locale,
);
const handleChange = async (value: string) => {
const updatedUser = await updateUser({ locale: value });
setLanguage(value);
setUser(updatedUser);
i18n.changeLanguage(value);
};
return (
<Select
label={t("Select language")}
data={[
{ value: "en-US", label: "English (United States)" },
{ value: "zh-CN", label: "中文 (简体)" },
]}
value={language}
onChange={handleChange}
allowDeselect={false}
checkIconPosition="right"
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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