diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef1ad5711..7880d0e42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,11 +15,14 @@ "@tanstack/react-router": "1.19.1", "axios": "1.9.0", "form-data": "4.0.2", + "i18next": "^25.3.2", + "i18next-browser-languagedetector": "^8.2.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^5.0.0", "react-hook-form": "7.49.3", + "react-i18next": "^15.6.0", "react-icons": "^5.5.0" }, "devDependencies": { @@ -300,13 +303,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -3381,6 +3381,15 @@ "react-is": "^16.7.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -3391,6 +3400,46 @@ "node": ">=16.17.0" } }, + "node_modules/i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4002,6 +4051,32 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -4030,11 +4105,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4254,7 +4324,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4379,6 +4449,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4644,12 +4723,9 @@ } }, "@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "requires": { - "regenerator-runtime": "^0.14.0" - } + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==" }, "@babel/types": { "version": "7.23.9", @@ -6669,12 +6745,36 @@ "react-is": "^16.7.0" } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, "human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true }, + "i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "requires": { + "@babel/runtime": "^7.27.6" + } + }, + "i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "requires": { + "@babel/runtime": "^7.23.2" + } + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7074,6 +7174,15 @@ "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", "requires": {} }, + "react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "requires": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + } + }, "react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -7091,11 +7200,6 @@ "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "dev": true }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -7246,7 +7350,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true + "devOptional": true }, "ufo": { "version": "1.5.4", @@ -7293,6 +7397,11 @@ "tinyglobby": "^0.2.13" } }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1760a3472..7febc568f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,14 @@ "@tanstack/react-router": "1.19.1", "axios": "1.9.0", "form-data": "4.0.2", + "i18next": "^25.3.2", + "i18next-browser-languagedetector": "^8.2.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^5.0.0", "react-hook-form": "7.49.3", + "react-i18next": "^15.6.0", "react-icons": "^5.5.0" }, "devDependencies": { diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx index db353a3a2..8abdbdac1 100644 --- a/frontend/src/components/Admin/AddUser.tsx +++ b/frontend/src/components/Admin/AddUser.tsx @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { Controller, type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { type UserCreate, UsersService } from "@/client" import type { ApiError } from "@/client/core/ApiError" @@ -33,6 +34,7 @@ interface UserCreateForm extends UserCreate { } const AddUser = () => { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() @@ -60,7 +62,7 @@ const AddUser = () => { mutationFn: (data: UserCreate) => UsersService.createUser({ requestBody: data }), onSuccess: () => { - showSuccessToast("User created successfully.") + showSuccessToast(t("messages.success.userCreated")) reset() setIsOpen(false) }, @@ -86,32 +88,32 @@ const AddUser = () => {
- Add User + {t("user.addUser")} - Fill in the form below to add a new user to the system. + {t("forms.fillUserDetails")} @@ -119,12 +121,12 @@ const AddUser = () => { @@ -133,18 +135,18 @@ const AddUser = () => { required invalid={!!errors.password} errorText={errors.password?.message} - label="Set Password" + label={t("user.setPassword")} > @@ -153,17 +155,17 @@ const AddUser = () => { required invalid={!!errors.confirm_password} errorText={errors.confirm_password?.message} - label="Confirm Password" + label={t("auth.confirmPassword")} > value === getValues().password || - "The passwords do not match", + t("forms.passwordsDoNotMatch"), })} - placeholder="Password" + placeholder={t("auth.password")} type="password" /> @@ -179,7 +181,7 @@ const AddUser = () => { checked={field.value} onCheckedChange={({ checked }) => field.onChange(checked)} > - Is superuser? + {t("user.isSuperuser")} )} @@ -193,7 +195,7 @@ const AddUser = () => { checked={field.value} onCheckedChange={({ checked }) => field.onChange(checked)} > - Is active? + {t("user.isActive")} )} @@ -208,7 +210,7 @@ const AddUser = () => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t("common.cancel")} diff --git a/frontend/src/components/Admin/DeleteUser.tsx b/frontend/src/components/Admin/DeleteUser.tsx index f3e7db317..a2773adf8 100644 --- a/frontend/src/components/Admin/DeleteUser.tsx +++ b/frontend/src/components/Admin/DeleteUser.tsx @@ -2,6 +2,7 @@ import { Button, DialogTitle, Text } from "@chakra-ui/react" import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiTrash2 } from "react-icons/fi" import { UsersService } from "@/client" @@ -18,6 +19,7 @@ import { import useCustomToast from "@/hooks/useCustomToast" const DeleteUser = ({ id }: { id: string }) => { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() @@ -33,11 +35,11 @@ const DeleteUser = ({ id }: { id: string }) => { const mutation = useMutation({ mutationFn: deleteUser, onSuccess: () => { - showSuccessToast("The user was deleted successfully") + showSuccessToast(t("messages.success.userDeleted")) setIsOpen(false) }, onError: () => { - showErrorToast("An error occurred while deleting the user") + showErrorToast(t("messages.error.userDeleteError")) }, onSettled: () => { queryClient.invalidateQueries() @@ -59,19 +61,17 @@ const DeleteUser = ({ id }: { id: string }) => {
- Delete User + {t("user.deleteUser")} - All items associated with this user will also be{" "} - permanently deleted. Are you sure? You will not - be able to undo this action. + {t("messages.confirmation.deleteUser")} @@ -82,7 +82,7 @@ const DeleteUser = ({ id }: { id: string }) => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t("common.cancel")} diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx index 6195fcce8..22a483b76 100644 --- a/frontend/src/components/Admin/EditUser.tsx +++ b/frontend/src/components/Admin/EditUser.tsx @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { Controller, type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { Button, @@ -41,6 +42,7 @@ const EditUser = ({ user }: EditUserProps) => { const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() + const { t } = useTranslation() const { control, register, @@ -58,7 +60,7 @@ const EditUser = ({ user }: EditUserProps) => { mutationFn: (data: UserUpdateForm) => UsersService.updateUser({ userId: user.id, requestBody: data }), onSuccess: () => { - showSuccessToast("User updated successfully.") + showSuccessToast(t("messages.success.userUpdated")) reset() setIsOpen(false) }, @@ -87,30 +89,30 @@ const EditUser = ({ user }: EditUserProps) => { - Edit User + {t("user.editUser")} - Update the user details below. + {t("forms.updateUserDetails")} @@ -118,12 +120,12 @@ const EditUser = ({ user }: EditUserProps) => { @@ -131,17 +133,17 @@ const EditUser = ({ user }: EditUserProps) => { @@ -149,16 +151,16 @@ const EditUser = ({ user }: EditUserProps) => { value === getValues().password || - "The passwords do not match", + t("forms.passwordsDoNotMatch"), })} - placeholder="Password" + placeholder={t("auth.password")} type="password" /> @@ -174,7 +176,7 @@ const EditUser = ({ user }: EditUserProps) => { checked={field.value} onCheckedChange={({ checked }) => field.onChange(checked)} > - Is superuser? + {t("user.isSuperuser")} )} @@ -188,7 +190,7 @@ const EditUser = ({ user }: EditUserProps) => { checked={field.value} onCheckedChange={({ checked }) => field.onChange(checked)} > - Is active? + {t("user.isActive")} )} @@ -203,11 +205,11 @@ const EditUser = ({ user }: EditUserProps) => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t("common.cancel")} diff --git a/frontend/src/components/Common/LanguageSwitcher.tsx b/frontend/src/components/Common/LanguageSwitcher.tsx new file mode 100644 index 000000000..017d67549 --- /dev/null +++ b/frontend/src/components/Common/LanguageSwitcher.tsx @@ -0,0 +1,44 @@ +import { Button, Text } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { FiGlobe } from "react-icons/fi"; +import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"; + +const LanguageSwitcher = () => { + const { i18n, t } = useTranslation(); + + const changeLanguage = (lng: string) => { + i18n.changeLanguage(lng); + }; + + const currentLanguage = + i18n.language === "zh" ? t("language.chinese") : t("language.english"); + + return ( + + + + + + changeLanguage("en")} + style={{ cursor: "pointer" }} + > + {t("language.english")} + + changeLanguage("zh")} + style={{ cursor: "pointer" }} + > + {t("language.chinese")} + + + + ); +}; + +export default LanguageSwitcher; diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index 7e952e005..2c39aba63 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -1,11 +1,14 @@ import { Flex, Image, useBreakpointValue } from "@chakra-ui/react" import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import Logo from "/assets/images/fastapi-logo.svg" +import LanguageSwitcher from "./LanguageSwitcher" import UserMenu from "./UserMenu" function Navbar() { const display = useBreakpointValue({ base: "none", md: "flex" }) + const { t } = useTranslation() return ( - Logo + {t("common.logo")} + diff --git a/frontend/src/components/Common/NotFound.tsx b/frontend/src/components/Common/NotFound.tsx index 2a00f2b38..c95417d00 100644 --- a/frontend/src/components/Common/NotFound.tsx +++ b/frontend/src/components/Common/NotFound.tsx @@ -1,7 +1,10 @@ import { Button, Center, Flex, Text } from "@chakra-ui/react" import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" const NotFound = () => { + const { t } = useTranslation() + return ( <> { lineHeight="1" mb={4} > - 404 + {t('notFound.title')} - Oops! + {t('notFound.subtitle')} @@ -35,7 +38,7 @@ const NotFound = () => { textAlign="center" zIndex={1} > - The page you are looking for was not found. + {t('notFound.description')}
@@ -45,7 +48,7 @@ const NotFound = () => { mt={4} alignSelf="center" > - Go Back + {t('notFound.goHome')}
diff --git a/frontend/src/components/Common/Sidebar.tsx b/frontend/src/components/Common/Sidebar.tsx index 8437634f4..f5d90ecc8 100644 --- a/frontend/src/components/Common/Sidebar.tsx +++ b/frontend/src/components/Common/Sidebar.tsx @@ -1,6 +1,7 @@ import { Box, Flex, IconButton, Text } from "@chakra-ui/react" import { useQueryClient } from "@tanstack/react-query" import { useState } from "react" +import { useTranslation } from "react-i18next" import { FaBars } from "react-icons/fa" import { FiLogOut } from "react-icons/fi" @@ -20,6 +21,7 @@ const Sidebar = () => { const queryClient = useQueryClient() const currentUser = queryClient.getQueryData(["currentUser"]) const { logout } = useAuth() + const { t } = useTranslation() const [open, setOpen] = useState(false) return ( @@ -36,7 +38,7 @@ const Sidebar = () => { variant="ghost" color="inherit" display={{ base: "flex", md: "none" }} - aria-label="Open Menu" + aria-label={t('navigation.menu')} position="absolute" zIndex="100" m={4} @@ -61,12 +63,12 @@ const Sidebar = () => { py={2} > - Log Out + {t('navigation.logout')} {currentUser?.email && ( - Logged in as: {currentUser.email} + {t('auth.email')}: {currentUser.email} )} diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 13f71495f..a5bc4280b 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -1,37 +1,39 @@ import { Box, Flex, Icon, Text } from "@chakra-ui/react" import { useQueryClient } from "@tanstack/react-query" import { Link as RouterLink } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" import type { IconType } from "react-icons/lib" import type { UserPublic } from "@/client" -const items = [ - { icon: FiHome, title: "Dashboard", path: "/" }, - { icon: FiBriefcase, title: "Items", path: "/items" }, - { icon: FiSettings, title: "User Settings", path: "/settings" }, -] - interface SidebarItemsProps { onClose?: () => void } interface Item { icon: IconType - title: string + titleKey: string path: string } const SidebarItems = ({ onClose }: SidebarItemsProps) => { const queryClient = useQueryClient() const currentUser = queryClient.getQueryData(["currentUser"]) + const { t } = useTranslation() + + const items: Item[] = [ + { icon: FiHome, titleKey: "navigation.dashboard", path: "/" }, + { icon: FiBriefcase, titleKey: "navigation.items", path: "/items" }, + { icon: FiSettings, titleKey: "navigation.userSettings", path: "/settings" }, + ] const finalItems: Item[] = currentUser?.is_superuser - ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }] + ? [...items, { icon: FiUsers, titleKey: "navigation.admin", path: "/admin" }] : items - const listItems = finalItems.map(({ icon, title, path }) => ( - + const listItems = finalItems.map(({ icon, titleKey, path }) => ( + { fontSize="sm" > - {title} + {t(titleKey)} )) @@ -51,7 +53,7 @@ const SidebarItems = ({ onClose }: SidebarItemsProps) => { return ( <> - Menu + {t('navigation.menu')} {listItems} diff --git a/frontend/src/components/Common/UserMenu.tsx b/frontend/src/components/Common/UserMenu.tsx index 5f2b26ad4..cc21b0d54 100644 --- a/frontend/src/components/Common/UserMenu.tsx +++ b/frontend/src/components/Common/UserMenu.tsx @@ -1,5 +1,6 @@ import { Box, Button, Flex, Text } from "@chakra-ui/react" import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import { FaUserAstronaut } from "react-icons/fa" import { FiLogOut, FiUser } from "react-icons/fi" @@ -8,6 +9,7 @@ import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu" const UserMenu = () => { const { user, logout } = useAuth() + const { t } = useTranslation() const handleLogout = async () => { logout() @@ -21,7 +23,7 @@ const UserMenu = () => { @@ -35,7 +37,7 @@ const UserMenu = () => { style={{ cursor: "pointer" }} > - My Profile + {t('user.profile')} @@ -47,7 +49,7 @@ const UserMenu = () => { style={{ cursor: "pointer" }} > - Log Out + {t('navigation.logout')} diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx index e7b3104d4..de467b2fc 100644 --- a/frontend/src/components/Items/AddItem.tsx +++ b/frontend/src/components/Items/AddItem.tsx @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { Button, @@ -31,6 +32,7 @@ const AddItem = () => { const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() + const { t } = useTranslation() const { register, handleSubmit, @@ -49,7 +51,7 @@ const AddItem = () => { mutationFn: (data: ItemCreate) => ItemsService.createItem({ requestBody: data }), onSuccess: () => { - showSuccessToast("Item created successfully.") + showSuccessToast(t('messages.success.itemCreated')) reset() setIsOpen(false) }, @@ -75,29 +77,29 @@ const AddItem = () => { - Add Item + {t('items.addItem')} - Fill in the details to add a new item. + {t('forms.fillDetails')} @@ -105,12 +107,12 @@ const AddItem = () => { @@ -124,7 +126,7 @@ const AddItem = () => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t('common.cancel')} diff --git a/frontend/src/components/Items/DeleteItem.tsx b/frontend/src/components/Items/DeleteItem.tsx index ea3b7fdc7..184781601 100644 --- a/frontend/src/components/Items/DeleteItem.tsx +++ b/frontend/src/components/Items/DeleteItem.tsx @@ -2,6 +2,7 @@ import { Button, DialogTitle, Text } from "@chakra-ui/react" import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiTrash2 } from "react-icons/fi" import { ItemsService } from "@/client" @@ -21,6 +22,7 @@ const DeleteItem = ({ id }: { id: string }) => { const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() + const { t } = useTranslation() const { handleSubmit, formState: { isSubmitting }, @@ -33,11 +35,11 @@ const DeleteItem = ({ id }: { id: string }) => { const mutation = useMutation({ mutationFn: deleteItem, onSuccess: () => { - showSuccessToast("The item was deleted successfully") + showSuccessToast(t('messages.success.itemDeleted')) setIsOpen(false) }, onError: () => { - showErrorToast("An error occurred while deleting the item") + showErrorToast(t('messages.error.itemDeleteError')) }, onSettled: () => { queryClient.invalidateQueries() @@ -59,7 +61,7 @@ const DeleteItem = ({ id }: { id: string }) => { @@ -67,12 +69,11 @@ const DeleteItem = ({ id }: { id: string }) => {
- Delete Item + {t('items.deleteItem')} - This item will be permanently deleted. Are you sure? You will not - be able to undo this action. + {t('messages.confirmation.deleteItem')} @@ -83,7 +84,7 @@ const DeleteItem = ({ id }: { id: string }) => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t('common.cancel')} diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx index 49c4eaa0b..7ddf4d0b9 100644 --- a/frontend/src/components/Items/EditItem.tsx +++ b/frontend/src/components/Items/EditItem.tsx @@ -9,6 +9,7 @@ import { import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FaExchangeAlt } from "react-icons/fa" import { type ApiError, type ItemPublic, ItemsService } from "@/client" @@ -39,6 +40,7 @@ const EditItem = ({ item }: EditItemProps) => { const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() + const { t } = useTranslation() const { register, handleSubmit, @@ -57,7 +59,7 @@ const EditItem = ({ item }: EditItemProps) => { mutationFn: (data: ItemUpdateForm) => ItemsService.updateItem({ id: item.id, requestBody: data }), onSuccess: () => { - showSuccessToast("Item updated successfully.") + showSuccessToast(t('messages.success.itemUpdated')) reset() setIsOpen(false) }, @@ -83,29 +85,29 @@ const EditItem = ({ item }: EditItemProps) => {
- Edit Item + {t('items.editItem')} - Update the item details below. + {t('forms.updateDetails')} @@ -113,12 +115,12 @@ const EditItem = ({ item }: EditItemProps) => { @@ -133,11 +135,11 @@ const EditItem = ({ item }: EditItemProps) => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t('common.cancel')} diff --git a/frontend/src/components/Pending/PendingItems.tsx b/frontend/src/components/Pending/PendingItems.tsx index 0afc50477..4ffa13803 100644 --- a/frontend/src/components/Pending/PendingItems.tsx +++ b/frontend/src/components/Pending/PendingItems.tsx @@ -1,35 +1,40 @@ import { Table } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" import { SkeletonText } from "../ui/skeleton" -const PendingItems = () => ( - - - - ID - Title - Description - Actions - - - - {[...Array(5)].map((_, index) => ( - - - - - - - - - - - - - +const PendingItems = () => { + const { t } = useTranslation() + + return ( + + + + {t("common.id")} + {t("common.title")} + {t("common.description")} + {t("common.actions")} - ))} - - -) + + + {[...Array(5)].map((_, index) => ( + + + + + + + + + + + + + + + ))} + + + ) +} export default PendingItems diff --git a/frontend/src/components/UserSettings/Appearance.tsx b/frontend/src/components/UserSettings/Appearance.tsx index a94174163..f003b9b03 100644 --- a/frontend/src/components/UserSettings/Appearance.tsx +++ b/frontend/src/components/UserSettings/Appearance.tsx @@ -1,16 +1,18 @@ import { Container, Heading, Stack } from "@chakra-ui/react" import { useTheme } from "next-themes" +import { useTranslation } from "react-i18next" import { Radio, RadioGroup } from "@/components/ui/radio" const Appearance = () => { const { theme, setTheme } = useTheme() + const { t } = useTranslation() return ( <> - Appearance + {t("user.appearance")} { colorPalette="teal" > - System - Light Mode - Dark Mode + {t("theme.system")} + {t("theme.light")} + {t("theme.dark")} diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx index 55e6167a4..1ce33554e 100644 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -1,6 +1,7 @@ import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react" import { useMutation } from "@tanstack/react-query" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiLock } from "react-icons/fi" import { type ApiError, type UpdatePassword, UsersService } from "@/client" @@ -13,6 +14,7 @@ interface UpdatePasswordForm extends UpdatePassword { } const ChangePassword = () => { + const { t } = useTranslation() const { showSuccessToast } = useCustomToast() const { register, @@ -29,7 +31,7 @@ const ChangePassword = () => { mutationFn: (data: UpdatePassword) => UsersService.updatePasswordMe({ requestBody: data }), onSuccess: () => { - showSuccessToast("Password updated successfully.") + showSuccessToast(t("auth.passwordUpdatedSuccessfully")) reset() }, onError: (err: ApiError) => { @@ -45,29 +47,29 @@ const ChangePassword = () => { <> - Change Password + {t("user.changePassword")} } - {...register("current_password", passwordRules())} - placeholder="Current Password" + {...register("current_password", passwordRules(t))} + placeholder={t("auth.currentPassword")} errors={errors} /> } - {...register("new_password", passwordRules())} - placeholder="New Password" + {...register("new_password", passwordRules(t))} + placeholder={t("auth.newPassword")} errors={errors} /> } - {...register("confirm_password", confirmPasswordRules(getValues))} - placeholder="Confirm Password" + {...register("confirm_password", confirmPasswordRules(getValues, t))} + placeholder={t("auth.confirmPassword")} errors={errors} /> @@ -78,7 +80,7 @@ const ChangePassword = () => { loading={isSubmitting} disabled={!isValid} > - Save + {t("common.save")} diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx index 5800c98fe..5be4e6c2a 100644 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ b/frontend/src/components/UserSettings/DeleteAccount.tsx @@ -1,16 +1,18 @@ import { Container, Heading, Text } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" import DeleteConfirmation from "./DeleteConfirmation" const DeleteAccount = () => { + const { t } = useTranslation() + return ( - Delete Account + {t("user.deleteAccount")} - Permanently delete your data and everything associated with your - account. + {t("messages.confirmation.deleteAccount")} diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx index 67455d06b..741775e6c 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -2,6 +2,7 @@ import { Button, ButtonGroup, Text } from "@chakra-ui/react" import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { type ApiError, UsersService } from "@/client" import { @@ -20,6 +21,7 @@ import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" const DeleteConfirmation = () => { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() @@ -32,7 +34,7 @@ const DeleteConfirmation = () => { const mutation = useMutation({ mutationFn: () => UsersService.deleteUserMe(), onSuccess: () => { - showSuccessToast("Your account has been successfully deleted") + showSuccessToast(t("messages.success.accountDeleted")) setIsOpen(false) logout() }, @@ -59,7 +61,7 @@ const DeleteConfirmation = () => { > @@ -67,14 +69,11 @@ const DeleteConfirmation = () => { - Confirmation Required + {t("messages.confirmation.confirmationRequired")} - All your account data will be{" "} - permanently deleted. If you are sure, please - click "Confirm" to proceed. This action cannot - be undone. + {t("messages.confirmation.deleteAccount")} @@ -86,7 +85,7 @@ const DeleteConfirmation = () => { colorPalette="gray" disabled={isSubmitting} > - Cancel + {t("common.cancel")} diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx index a7b7c83cc..805252a37 100644 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ b/frontend/src/components/UserSettings/UserInformation.tsx @@ -10,6 +10,7 @@ import { import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { type ApiError, @@ -23,6 +24,7 @@ import { emailPattern, handleError } from "@/utils" import { Field } from "../ui/field" const UserInformation = () => { + const { t } = useTranslation() const queryClient = useQueryClient() const { showSuccessToast } = useCustomToast() const [editMode, setEditMode] = useState(false) @@ -50,7 +52,7 @@ const UserInformation = () => { mutationFn: (data: UserUpdateMe) => UsersService.updateUserMe({ requestBody: data }), onSuccess: () => { - showSuccessToast("User updated successfully.") + showSuccessToast(t("messages.success.userUpdated")) }, onError: (err: ApiError) => { handleError(err) @@ -73,14 +75,14 @@ const UserInformation = () => { <> - User Information + {t("user.userInformation")} - + {editMode ? ( { {editMode ? ( { loading={editMode ? isSubmitting : false} disabled={editMode ? !isDirty || !getValues("email") : false} > - {editMode ? "Save" : "Edit"} + {editMode ? t("common.save") : t("common.edit")} {editMode && ( )} diff --git a/frontend/src/components/ui/close-button.tsx b/frontend/src/components/ui/close-button.tsx index 94af48859..ae59bd254 100644 --- a/frontend/src/components/ui/close-button.tsx +++ b/frontend/src/components/ui/close-button.tsx @@ -1,6 +1,7 @@ import type { ButtonProps } from "@chakra-ui/react" import { IconButton as ChakraIconButton } from "@chakra-ui/react" import * as React from "react" +import { useTranslation } from "react-i18next" import { LuX } from "react-icons/lu" export type CloseButtonProps = ButtonProps @@ -9,8 +10,9 @@ export const CloseButton = React.forwardRef< HTMLButtonElement, CloseButtonProps >(function CloseButton(props, ref) { + const { t } = useTranslation() return ( - + {props.children ?? } ) diff --git a/frontend/src/components/ui/color-mode.tsx b/frontend/src/components/ui/color-mode.tsx index f93feabca..b2513017a 100644 --- a/frontend/src/components/ui/color-mode.tsx +++ b/frontend/src/components/ui/color-mode.tsx @@ -5,6 +5,7 @@ import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react" import { ThemeProvider, useTheme } from "next-themes" import type { ThemeProviderProps } from "next-themes" import * as React from "react" +import { useTranslation } from "react-i18next" import { LuMoon, LuSun } from "react-icons/lu" export interface ColorModeProviderProps extends ThemeProviderProps {} @@ -52,12 +53,13 @@ export const ColorModeButton = React.forwardRef< ColorModeButtonProps >(function ColorModeButton(props, ref) { const { toggleColorMode } = useColorMode() + const { t } = useTranslation() return ( }> ( const VisibilityTrigger = forwardRef( function VisibilityTrigger(props, ref) { + const { t } = useTranslation() + return ( ( size="sm" variant="ghost" height="calc(100% - {spacing.2})" - aria-label="Toggle password visibility" + aria-label={t("auth.togglePasswordVisibility")} color="inherit" {...props} /> @@ -151,12 +154,14 @@ export const PasswordStrengthMeter = forwardRef< }) function getColorPalette(percent: number) { + const { t } = useTranslation() + switch (true) { case percent < 33: - return { label: "Low", colorPalette: "red" } + return { label: t("auth.passwordStrengthLow"), colorPalette: "red" } case percent < 66: - return { label: "Medium", colorPalette: "orange" } + return { label: t("auth.passwordStrengthMedium"), colorPalette: "orange" } default: - return { label: "High", colorPalette: "green" } + return { label: t("auth.passwordStrengthHigh"), colorPalette: "green" } } } diff --git a/frontend/src/hooks/useCustomToast.ts b/frontend/src/hooks/useCustomToast.ts index fb0462351..c801974ae 100644 --- a/frontend/src/hooks/useCustomToast.ts +++ b/frontend/src/hooks/useCustomToast.ts @@ -1,11 +1,14 @@ "use client" +import { useTranslation } from "react-i18next" import { toaster } from "@/components/ui/toaster" const useCustomToast = () => { + const { t } = useTranslation() + const showSuccessToast = (description: string) => { toaster.create({ - title: "Success!", + title: t("common.success"), description, type: "success", }) @@ -13,7 +16,7 @@ const useCustomToast = () => { const showErrorToast = (description: string) => { toaster.create({ - title: "Something went wrong!", + title: t("common.error"), description, type: "error", }) diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 000000000..9b0d944f9 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,35 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' + +import en from './locales/en.json' +import zh from './locales/zh.json' + +const resources = { + en: { + translation: en + }, + zh: { + translation: zh + } +} + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + debug: false, + + interpolation: { + escapeValue: false + }, + + detection: { + order: ['localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage'] + } + }) + +export default i18n \ No newline at end of file diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 000000000..c27d4f712 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,193 @@ +{ + "dashboard": { + "greeting": "Hi, {{name}} 👋🏼", + "welcomeBack": "Welcome back, nice to see you again!" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "create": "Create", + "update": "Update", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "previous": "Previous", + "submit": "Submit", + "reset": "Reset", + "search": "Search", + "filter": "Filter", + "sort": "Sort", + "loading": "Loading...", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "yes": "Yes", + "no": "No", + "ok": "OK", + "close": "Close", + "logo": "Logo", + "actions": "Actions", + "continue": "Continue", + "id": "ID", + "notAvailable": "N/A", + "title": "Title", + "description": "Description" + }, + "navigation": { + "dashboard": "Dashboard", + "items": "Items", + "userSettings": "User Settings", + "admin": "Admin", + "menu": "Menu", + "logout": "Log Out", + "login": "Login", + "signup": "Sign Up" + }, + "auth": { + "login": "Login", + "signup": "Sign Up", + "logout": "Log Out", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "fullName": "Full Name", + "forgotPassword": "Forgot Password?", + "rememberMe": "Remember me", + "dontHaveAccount": "Don't have an account?", + "alreadyHaveAccount": "Already have an account?", + "signUpHere": "Sign up here", + "loginHere": "Login here", + "passwordRecovery": "Password Recovery", + "resetPassword": "Reset Password", + "newPassword": "New Password", + "currentPassword": "Current Password", + "passwordRecoveryText": "A password recovery email will be sent to the registered account.", + "resetPasswordText": "Please enter your new password and confirm it to reset your password.", + "logIn": "Log In", + "noAccount": "Don't have an account?", + "signUp": "Sign Up", + "haveAccount": "Already have an account?", + "passwordRecoveryDescription": "A password recovery email will be sent to the registered account.", + "passwordRecoveryEmailSent": "Password recovery email sent successfully.", + "resetPasswordDescription": "Please enter your new password and confirm it to reset your password.", + "passwordUpdatedSuccessfully": "Password updated successfully.", + "signupLink": "Don't have an account? Sign up", + "loginLink": "Already have an account? Log in", + "recoverPassword": "Recover Password", + "loginSuccess": "Login successful", + "logoutSuccess": "Logout successful", + "signupSuccess": "Signup successful", + "passwordResetSuccess": "Password reset successful", + "invalidCredentials": "Invalid username or password", + "accountNotFound": "Account not found", + "emailSent": "Email sent", + "tokenExpired": "Token expired", + "passwordMismatch": "Passwords do not match", + "togglePasswordVisibility": "Toggle password visibility", + "passwordStrengthLow": "Low", + "passwordStrengthMedium": "Medium", + "passwordStrengthHigh": "High" + }, + "user": { + "profile": "My profile", + "userInformation": "User Information", + "userSettings": "User Settings", + "changePassword": "Change Password", + "deleteAccount": "Danger zone", + "appearance": "Appearance", + "fullName": "Full Name", + "email": "Email", + "isSuperuser": "Is superuser?", + "isActive": "Is active?", + "addUser": "Add User", + "editUser": "Edit User", + "deleteUser": "Delete User", + "setPassword": "Set Password", + "users": "Users", + "usersManagement": "Users Management", + "role": "Role", + "status": "Status", + "you": "You", + "superuser": "Superuser", + "user": "User", + "active": "Active", + "inactive": "Inactive" + }, + "items": { + "items": "Items", + "addItem": "Add Item", + "editItem": "Edit Item", + "deleteItem": "Delete Item", + "title": "Title", + "description": "Description", + "id": "ID", + "owner": "Owner", + "noItems": "No items found", + "itemDetails": "Item Details", + "itemsManagement": "Items Management", + "noItemsYet": "You don't have any items yet", + "addNewItemToStart": "Add a new item to get started" + }, + "forms": { + "required": "This field is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "titleRequired": "Title is required", + "emailInvalid": "Please enter a valid email address", + "passwordMinLength": "Password must be at least 8 characters", + "passwordsDoNotMatch": "The passwords do not match", + "pleaseConfirmPassword": "Please confirm your password", + "fillDetails": "Fill in the details to add a new item.", + "updateDetails": "Update the item details below.", + "fillUserDetails": "Fill in the form below to add a new user to the system.", + "updateUserDetails": "Update the user details below.", + "usernameRequired": "Username is required", + "fullNameRequired": "Full Name is required" + }, + "messages": { + "success": { + "itemCreated": "Item created successfully.", + "itemUpdated": "Item updated successfully.", + "itemDeleted": "The item was deleted successfully", + "userCreated": "User created successfully.", + "userUpdated": "User updated successfully.", + "userDeleted": "The user was deleted successfully", + "passwordUpdated": "Password updated successfully.", + "accountDeleted": "Your account has been successfully deleted", + "passwordRecoveryEmailSent": "Password recovery email sent successfully." + }, + "error": { + "itemDeleteError": "An error occurred while deleting the item", + "userDeleteError": "An error occurred while deleting the user", + "generalError": "An error occurred. Please try again.", + "somethingWentWrong": "Something went wrong" + }, + "confirmation": { + "deleteItem": "This item will be permanently deleted. Are you sure? You will not be able to undo this action.", + "deleteUser": "All items associated with this user will also be permanently deleted. Are you sure? You will not be able to undo this action.", + "deleteAccount": "All your account data will be permanently deleted. If you are sure, please click \"Confirm\" to proceed. This action cannot be undone.", + "confirmationRequired": "Confirmation Required" + } + }, + "notFound": { + "title": "404", + "subtitle": "Page Not Found", + "description": "The page you are looking for does not exist.", + "goHome": "Go Home" + }, + "language": { + "switchLanguage": "Switch Language", + "english": "English", + "chinese": "中文" + }, + "theme": { + "light": "Light", + "dark": "Dark", + "system": "System", + "toggleColorMode": "Toggle color mode" + } +} diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json new file mode 100644 index 000000000..9cf53422e --- /dev/null +++ b/frontend/src/i18n/locales/zh.json @@ -0,0 +1,199 @@ +{ + "dashboard": { + "greeting": "您好,{{name}} 👋🏼", + "welcomeBack": "欢迎回来,很高兴再次见到您!" + }, + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "add": "添加", + "create": "创建", + "update": "更新", + "confirm": "确认", + "close": "关闭", + "loading": "加载中...", + "search": "搜索", + "filter": "筛选", + "sort": "排序", + "actions": "操作", + "yes": "是", + "no": "否", + "ok": "确定", + "continue": "继续", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "id": "ID", + "notAvailable": "不可用", + "success": "成功!", + "error": "出现错误!", + "warning": "警告", + "info": "信息", + "title": "标题", + "description": "描述", + "logo": "标志" + }, + "navigation": { + "dashboard": "仪表板", + "items": "项目", + "userSettings": "用户设置", + "admin": "管理员", + "settings": "设置", + "userManagement": "用户管理", + "menu": "菜单", + "logout": "退出登录", + "login": "登录", + "signup": "注册" + }, + "auth": { + "login": "登录", + "signup": "注册", + "logout": "退出登录", + "signupLink": "还没有账户?注册", + "loginLink": "已有账户?登录", + "email": "邮箱", + "password": "密码", + "confirmPassword": "确认密码", + "fullName": "全名", + "forgotPassword": "忘记密码?", + "resetPassword": "重置密码", + "recoverPassword": "找回密码", + "newPassword": "新密码", + "currentPassword": "当前密码", + "rememberMe": "记住我", + "loginSuccess": "登录成功", + "logoutSuccess": "退出成功", + "signupSuccess": "注册成功", + "passwordResetSuccess": "密码重置成功", + "invalidCredentials": "用户名或密码错误", + "accountNotFound": "账户不存在", + "emailSent": "邮件已发送", + "tokenExpired": "令牌已过期", + "passwordMismatch": "密码不匹配", + "togglePasswordVisibility": "切换密码可见性", + "passwordStrengthLow": "弱", + "passwordStrengthMedium": "中", + "passwordStrengthHigh": "强", + "dontHaveAccount": "还没有账户?", + "alreadyHaveAccount": "已有账户?", + "signUpHere": "在此注册", + "loginHere": "在此登录", + "passwordRecovery": "密码恢复", + "passwordRecoveryDescription": "密码恢复邮件将发送到注册的账户。", + "passwordRecoveryText": "密码恢复邮件将发送到注册的账户。", + "resetPasswordDescription": "请输入您的新密码并确认以重置密码。", + "resetPasswordText": "请输入您的新密码并确认以重置密码。", + "logIn": "登录", + "noAccount": "还没有账户?", + "signUp": "注册", + "haveAccount": "已有账户?", + "passwordRecoveryEmailSent": "密码恢复邮件发送成功。", + "passwordUpdatedSuccessfully": "密码更新成功。" + }, + "user": { + "profile": "我的资料", + "userInformation": "用户信息", + "userSettings": "用户设置", + "changePassword": "修改密码", + "deleteAccount": "危险区域", + "appearance": "外观", + "fullName": "全名", + "email": "邮箱", + "isSuperuser": "是超级用户?", + "isActive": "是否活跃?", + "addUser": "添加用户", + "editUser": "编辑用户", + "deleteUser": "删除用户", + "setPassword": "设置密码", + "users": "用户", + "usersManagement": "用户管理", + "role": "角色", + "status": "状态", + "you": "您", + "superuser": "超级用户", + "user": "用户", + "active": "活跃", + "inactive": "非活跃" + }, + "items": { + "items": "项目", + "addItem": "添加项目", + "createItem": "创建项目", + "editItem": "编辑项目", + "deleteItem": "删除项目", + "itemCreated": "项目创建成功", + "itemUpdated": "项目更新成功", + "itemDeleted": "项目删除成功", + "title": "标题", + "description": "描述", + "id": "ID", + "owner": "所有者", + "noItems": "没有项目", + "itemDetails": "项目详情", + "itemsManagement": "项目管理", + "noItemsYet": "您还没有任何项目", + "addNewItemToStart": "添加新项目开始使用" + }, + "forms": { + "required": "必填", + "usernameRequired": "用户名是必需的", + "emailRequired": "邮箱为必填项", + "emailInvalid": "邮箱格式无效", + "passwordRequired": "密码为必填项", + "passwordMinLength": "密码至少需要8个字符", + "fullNameRequired": "全名为必填项", + "passwordsDoNotMatch": "密码不匹配", + "pleaseConfirmPassword": "请确认密码", + "fillDetails": "请填写详细信息", + "updateDetails": "更新详细信息", + "fillUserDetails": "请填写用户详细信息", + "updateUserDetails": "更新用户详细信息", + "titleRequired": "标题为必填项" + }, + "messages": { + "success": { + "itemCreated": "项目创建成功。", + "itemUpdated": "项目更新成功。", + "itemDeleted": "项目删除成功", + "userCreated": "用户创建成功。", + "userUpdated": "用户更新成功。", + "userDeleted": "用户删除成功", + "passwordUpdated": "密码更新成功。", + "accountDeleted": "您的账户已成功删除", + "passwordRecoveryEmailSent": "密码恢复邮件发送成功。" + }, + "error": { + "itemDeleteError": "删除项目时发生错误", + "userDeleteError": "删除用户时发生错误", + "generalError": "发生错误,请重试。", + "somethingWentWrong": "出现了一些问题" + }, + "confirmation": { + "deleteItem": "此项目将被永久删除。您确定吗?此操作无法撤销。", + "deleteUser": "与此用户关联的所有项目也将被永久删除。您确定吗?此操作无法撤销。", + "deleteAccount": "您的所有账户数据将被永久删除。如果您确定,请点击确认继续。此操作无法撤销。", + "confirmationRequired": "需要确认" + } + }, + "notFound": { + "title": "404", + "subtitle": "页面未找到", + "description": "您要查找的页面不存在。", + "goHome": "返回首页" + }, + "language": { + "switchLanguage": "切换语言", + "english": "English", + "chinese": "中文" + }, + "theme": { + "light": "浅色", + "dark": "深色", + "system": "跟随系统", + "toggleColorMode": "切换颜色模式" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0d80ea6f8..cfc6cdec5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ import { routeTree } from "./routeTree.gen" import { ApiError, OpenAPI } from "./client" import { CustomProvider } from "./components/ui/provider" +import "./i18n" OpenAPI.BASE = import.meta.env.VITE_API_URL OpenAPI.TOKEN = async () => { diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 7a6ede729..e0992eb8d 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -1,6 +1,7 @@ import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import { z } from "zod" import { type UserPublic, UsersService } from "@/client" @@ -38,6 +39,7 @@ function UsersTable() { const currentUser = queryClient.getQueryData(["currentUser"]) const navigate = useNavigate({ from: Route.fullPath }) const { page } = Route.useSearch() + const { t } = useTranslation() const { data, isLoading, isPlaceholderData } = useQuery({ ...getUsersQueryOptions({ page }), @@ -61,21 +63,21 @@ function UsersTable() { - Full name - Email - Role - Status - Actions + {t("user.fullName")} + {t("user.email")} + {t("user.role")} + {t("user.status")} + {t("common.actions")} {users?.map((user) => ( - {user.full_name || "N/A"} + {user.full_name || t("common.notAvailable")} {currentUser?.id === user.id && ( - You + {t("user.you")} )} @@ -83,9 +85,9 @@ function UsersTable() { {user.email} - {user.is_superuser ? "Superuser" : "User"} + {user.is_superuser ? t("user.superuser") : t("user.user")} - {user.is_active ? "Active" : "Inactive"} + {user.is_active ? t("user.active") : t("user.inactive")} - Users Management + {t("user.usersManagement")} diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 031385405..e3fa8a8d2 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -1,5 +1,6 @@ import { Box, Container, Text } from "@chakra-ui/react" import { createFileRoute } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import useAuth from "@/hooks/useAuth" @@ -9,15 +10,16 @@ export const Route = createFileRoute("/_layout/")({ function Dashboard() { const { user: currentUser } = useAuth() + const { t } = useTranslation() return ( <> - Hi, {currentUser?.full_name || currentUser?.email} 👋🏼 + {t("dashboard.greeting", { name: currentUser?.full_name || currentUser?.email })} 👋🏼 - Welcome back, nice to see you again! + {t("dashboard.welcomeBack")} diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx index 8a2ef0707..e2a4f3f26 100644 --- a/frontend/src/routes/_layout/items.tsx +++ b/frontend/src/routes/_layout/items.tsx @@ -9,6 +9,7 @@ import { import { useQuery } from "@tanstack/react-query" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { FiSearch } from "react-icons/fi" +import { useTranslation } from "react-i18next" import { z } from "zod" import { ItemsService } from "@/client" @@ -44,6 +45,7 @@ export const Route = createFileRoute("/_layout/items")({ function ItemsTable() { const navigate = useNavigate({ from: Route.fullPath }) const { page } = Route.useSearch() + const { t } = useTranslation() const { data, isLoading, isPlaceholderData } = useQuery({ ...getItemsQueryOptions({ page }), @@ -70,9 +72,9 @@ function ItemsTable() { - You don't have any items yet + {t("items.noItemsYet")} - Add a new item to get started + {t("items.addNewItemToStart")} @@ -85,10 +87,10 @@ function ItemsTable() { - ID - Title - Description - Actions + {t("common.id")} + {t("items.title")} + {t("items.description")} + {t("common.actions")} @@ -105,7 +107,7 @@ function ItemsTable() { truncate maxW="30%" > - {item.description || "N/A"} + {item.description || t("common.notAvailable")} @@ -132,10 +134,12 @@ function ItemsTable() { } function Items() { + const { t } = useTranslation() + return ( - Items Management + {t("items.itemsManagement")} diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index 3a544acb8..19a2745d6 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -1,5 +1,6 @@ import { Container, Heading, Tabs } from "@chakra-ui/react" import { createFileRoute } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" import Appearance from "@/components/UserSettings/Appearance" import ChangePassword from "@/components/UserSettings/ChangePassword" @@ -7,19 +8,17 @@ import DeleteAccount from "@/components/UserSettings/DeleteAccount" import UserInformation from "@/components/UserSettings/UserInformation" import useAuth from "@/hooks/useAuth" -const tabsConfig = [ - { value: "my-profile", title: "My profile", component: UserInformation }, - { value: "password", title: "Password", component: ChangePassword }, - { value: "appearance", title: "Appearance", component: Appearance }, - { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, -] - -export const Route = createFileRoute("/_layout/settings")({ - component: UserSettings, -}) - function UserSettings() { const { user: currentUser } = useAuth() + const { t } = useTranslation() + + const tabsConfig = [ + { value: "my-profile", title: t("user.profile"), component: UserInformation }, + { value: "password", title: t("user.changePassword"), component: ChangePassword }, + { value: "appearance", title: t("user.appearance"), component: Appearance }, + { value: "danger-zone", title: t("user.deleteAccount"), component: DeleteAccount }, + ] + const finalTabs = currentUser?.is_superuser ? tabsConfig.slice(0, 3) : tabsConfig @@ -31,7 +30,7 @@ function UserSettings() { return ( - User Settings + {t("user.userSettings")} @@ -51,3 +50,7 @@ function UserSettings() { ) } + +export const Route = createFileRoute("/_layout/settings")({ + component: UserSettings, +}) diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 279aefd9a..75e2a59da 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -5,6 +5,7 @@ import { redirect, } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiLock, FiMail } from "react-icons/fi" import type { Body_login_login_access_token as AccessToken } from "@/client" @@ -29,6 +30,7 @@ export const Route = createFileRoute("/login")({ function Login() { const { loginMutation, error, resetError } = useAuth() + const { t } = useTranslation() const { register, handleSubmit, @@ -68,7 +70,7 @@ function Login() { > FastAPI logo @@ -93,20 +95,20 @@ function Login() { } - {...register("password", passwordRules())} - placeholder="Password" + {...register("password", passwordRules(t))} + placeholder={t("auth.password")} errors={errors} /> - Forgot Password? + {t("auth.forgotPassword")} - Don't have an account?{" "} + {t("auth.noAccount")}{" "} - Sign Up + {t("auth.signUp")} diff --git a/frontend/src/routes/recover-password.tsx b/frontend/src/routes/recover-password.tsx index afc159688..90121edf6 100644 --- a/frontend/src/routes/recover-password.tsx +++ b/frontend/src/routes/recover-password.tsx @@ -2,6 +2,7 @@ import { Container, Heading, Input, Text } from "@chakra-ui/react" import { useMutation } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiMail } from "react-icons/fi" import { type ApiError, LoginService } from "@/client" @@ -28,6 +29,7 @@ export const Route = createFileRoute("/recover-password")({ }) function RecoverPassword() { + const { t } = useTranslation() const { register, handleSubmit, @@ -45,7 +47,7 @@ function RecoverPassword() { const mutation = useMutation({ mutationFn: recoverPassword, onSuccess: () => { - showSuccessToast("Password recovery email sent successfully.") + showSuccessToast(t("auth.passwordRecoveryEmailSent")) reset() }, onError: (err: ApiError) => { @@ -69,26 +71,26 @@ function RecoverPassword() { centerContent > - Password Recovery + {t("auth.passwordRecovery")} - A password recovery email will be sent to the registered account. + {t("auth.passwordRecoveryDescription")} }> ) diff --git a/frontend/src/routes/reset-password.tsx b/frontend/src/routes/reset-password.tsx index f55f49e28..5386e196a 100644 --- a/frontend/src/routes/reset-password.tsx +++ b/frontend/src/routes/reset-password.tsx @@ -2,6 +2,7 @@ import { Container, Heading, Text } from "@chakra-ui/react" import { useMutation } from "@tanstack/react-query" import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiLock } from "react-icons/fi" import { type ApiError, LoginService, type NewPassword } from "@/client" @@ -27,6 +28,7 @@ export const Route = createFileRoute("/reset-password")({ }) function ResetPassword() { + const { t } = useTranslation() const { register, handleSubmit, @@ -54,7 +56,7 @@ function ResetPassword() { const mutation = useMutation({ mutationFn: resetPassword, onSuccess: () => { - showSuccessToast("Password updated successfully.") + showSuccessToast(t("auth.passwordUpdatedSuccessfully")) reset() navigate({ to: "/login" }) }, @@ -79,27 +81,27 @@ function ResetPassword() { centerContent > - Reset Password + {t("auth.resetPassword")} - Please enter your new password and confirm it to reset your password. + {t("auth.resetPasswordDescription")} } type="new_password" errors={errors} - {...register("new_password", passwordRules())} - placeholder="New Password" + {...register("new_password", passwordRules(t))} + placeholder={t("auth.newPassword")} /> } type="confirm_password" errors={errors} - {...register("confirm_password", confirmPasswordRules(getValues))} - placeholder="Confirm Password" + {...register("confirm_password", confirmPasswordRules(getValues, t))} + placeholder={t("auth.confirmPassword")} /> ) diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index 2668e3787..f4fe97240 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -5,6 +5,7 @@ import { redirect, } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { FiLock, FiUser } from "react-icons/fi" import type { UserRegister } from "@/client" @@ -33,6 +34,7 @@ interface UserRegisterForm extends UserRegister { function SignUp() { const { signUpMutation } = useAuth() + const { t } = useTranslation() const { register, handleSubmit, @@ -68,7 +70,7 @@ function SignUp() { > FastAPI logo @@ -96,10 +98,10 @@ function SignUp() { @@ -107,24 +109,24 @@ function SignUp() { } - {...register("password", passwordRules())} - placeholder="Password" + {...register("password", passwordRules(t))} + placeholder={t("auth.password")} errors={errors} /> } - {...register("confirm_password", confirmPasswordRules(getValues))} - placeholder="Confirm Password" + {...register("confirm_password", confirmPasswordRules(getValues, t))} + placeholder={t("auth.confirmPassword")} errors={errors} /> - Already have an account?{" "} + {t("auth.haveAccount")}{" "} - Log In + {t("auth.logIn")} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index ce1d184f9..93a7fa29c 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import type { ApiError } from "./client" import useCustomToast from "./hooks/useCustomToast" @@ -11,16 +12,16 @@ export const namePattern = { message: "Invalid name", } -export const passwordRules = (isRequired = true) => { +export const passwordRules = (t?: any, isRequired = true) => { const rules: any = { minLength: { value: 8, - message: "Password must be at least 8 characters", + message: t ? t("forms.passwordMinLength") : "Password must be at least 8 characters", }, } if (isRequired) { - rules.required = "Password is required" + rules.required = t ? t("forms.passwordRequired") : "Password is required" } return rules @@ -28,17 +29,18 @@ export const passwordRules = (isRequired = true) => { export const confirmPasswordRules = ( getValues: () => any, + t?: any, isRequired = true, ) => { const rules: any = { validate: (value: string) => { const password = getValues().password || getValues().new_password - return value === password ? true : "The passwords do not match" + return value === password ? true : (t ? t("forms.passwordsDoNotMatch") : "The passwords do not match") }, } if (isRequired) { - rules.required = "Password confirmation is required" + rules.required = t ? t("forms.pleaseConfirmPassword") : "Password confirmation is required" } return rules @@ -46,8 +48,9 @@ export const confirmPasswordRules = ( export const handleError = (err: ApiError) => { const { showErrorToast } = useCustomToast() + const { t } = useTranslation() const errDetail = (err.body as any)?.detail - let errorMessage = errDetail || "Something went wrong." + let errorMessage = errDetail || t("messages.error.somethingWentWrong") if (Array.isArray(errDetail) && errDetail.length > 0) { errorMessage = errDetail[0].msg }