Browse Source

feat(i18n): Add multi-language support to realize Chinese and English switching function

pull/13907/head
winter 2 weeks ago
parent
commit
b79a3cd6b9
  1. 157
      frontend/package-lock.json
  2. 3
      frontend/package.json
  3. 44
      frontend/src/components/Admin/AddUser.tsx
  4. 18
      frontend/src/components/Admin/DeleteUser.tsx
  5. 40
      frontend/src/components/Admin/EditUser.tsx
  6. 44
      frontend/src/components/Common/LanguageSwitcher.tsx
  7. 6
      frontend/src/components/Common/Navbar.tsx
  8. 11
      frontend/src/components/Common/NotFound.tsx
  9. 8
      frontend/src/components/Common/Sidebar.tsx
  10. 26
      frontend/src/components/Common/SidebarItems.tsx
  11. 8
      frontend/src/components/Common/UserMenu.tsx
  12. 24
      frontend/src/components/Items/AddItem.tsx
  13. 17
      frontend/src/components/Items/DeleteItem.tsx
  14. 24
      frontend/src/components/Items/EditItem.tsx
  15. 63
      frontend/src/components/Pending/PendingItems.tsx
  16. 10
      frontend/src/components/UserSettings/Appearance.tsx
  17. 20
      frontend/src/components/UserSettings/ChangePassword.tsx
  18. 8
      frontend/src/components/UserSettings/DeleteAccount.tsx
  19. 17
      frontend/src/components/UserSettings/DeleteConfirmation.tsx
  20. 16
      frontend/src/components/UserSettings/UserInformation.tsx
  21. 4
      frontend/src/components/ui/close-button.tsx
  22. 4
      frontend/src/components/ui/color-mode.tsx
  23. 13
      frontend/src/components/ui/password-input.tsx
  24. 7
      frontend/src/hooks/useCustomToast.ts
  25. 35
      frontend/src/i18n/index.ts
  26. 193
      frontend/src/i18n/locales/en.json
  27. 199
      frontend/src/i18n/locales/zh.json
  28. 1
      frontend/src/main.tsx
  29. 24
      frontend/src/routes/_layout/admin.tsx
  30. 6
      frontend/src/routes/_layout/index.tsx
  31. 20
      frontend/src/routes/_layout/items.tsx
  32. 27
      frontend/src/routes/_layout/settings.tsx
  33. 20
      frontend/src/routes/login.tsx
  34. 14
      frontend/src/routes/recover-password.tsx
  35. 18
      frontend/src/routes/reset-password.tsx
  36. 26
      frontend/src/routes/signup.tsx
  37. 15
      frontend/src/utils.ts

157
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",

3
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": {

44
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 = () => {
<DialogTrigger asChild>
<Button value="add-user" my={4}>
<FaPlus fontSize="16px" />
Add User
{t("user.addUser")}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogTitle>{t("user.addUser")}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
Fill in the form below to add a new user to the system.
{t("forms.fillUserDetails")}
</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
label={t("user.email")}
>
<Input
id="email"
{...register("email", {
required: "Email is required",
required: t("forms.emailRequired"),
pattern: emailPattern,
})}
placeholder="Email"
placeholder={t("user.email")}
type="email"
/>
</Field>
@ -119,12 +121,12 @@ const AddUser = () => {
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
label={t("user.fullName")}
>
<Input
id="name"
{...register("full_name")}
placeholder="Full name"
placeholder={t("user.fullName")}
type="text"
/>
</Field>
@ -133,18 +135,18 @@ const AddUser = () => {
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
label={t("user.setPassword")}
>
<Input
id="password"
{...register("password", {
required: "Password is required",
required: t("forms.passwordRequired"),
minLength: {
value: 8,
message: "Password must be at least 8 characters",
message: t("forms.passwordMinLength"),
},
})}
placeholder="Password"
placeholder={t("auth.password")}
type="password"
/>
</Field>
@ -153,17 +155,17 @@ const AddUser = () => {
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
label={t("auth.confirmPassword")}
>
<Input
id="confirm_password"
{...register("confirm_password", {
required: "Please confirm your password",
required: t("forms.pleaseConfirmPassword"),
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
t("forms.passwordsDoNotMatch"),
})}
placeholder="Password"
placeholder={t("auth.password")}
type="password"
/>
</Field>
@ -179,7 +181,7 @@ const AddUser = () => {
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
{t("user.isSuperuser")}
</Checkbox>
</Field>
)}
@ -193,7 +195,7 @@ const AddUser = () => {
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
{t("user.isActive")}
</Checkbox>
</Field>
)}
@ -208,7 +210,7 @@ const AddUser = () => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t("common.cancel")}
</Button>
</DialogActionTrigger>
<Button
@ -217,7 +219,7 @@ const AddUser = () => {
disabled={!isValid}
loading={isSubmitting}
>
Save
{t("common.save")}
</Button>
</DialogFooter>
</form>

18
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 }) => {
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete User
{t("user.deleteUser")}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogTitle>{t("user.deleteUser")}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
All items associated with this user will also be{" "}
<strong>permanently deleted.</strong> Are you sure? You will not
be able to undo this action.
{t("messages.confirmation.deleteUser")}
</Text>
</DialogBody>
@ -82,7 +82,7 @@ const DeleteUser = ({ id }: { id: string }) => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t("common.cancel")}
</Button>
</DialogActionTrigger>
<Button
@ -91,7 +91,7 @@ const DeleteUser = ({ id }: { id: string }) => {
type="submit"
loading={isSubmitting}
>
Delete
{t("common.delete")}
</Button>
</DialogFooter>
<DialogCloseTrigger />

40
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) => {
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<FaExchangeAlt fontSize="16px" />
Edit User
{t("user.editUser")}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogTitle>{t("user.editUser")}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the user details below.</Text>
<Text mb={4}>{t("forms.updateUserDetails")}</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
label={t("user.email")}
>
<Input
id="email"
{...register("email", {
required: "Email is required",
required: t("forms.emailRequired"),
pattern: emailPattern,
})}
placeholder="Email"
placeholder={t("user.email")}
type="email"
/>
</Field>
@ -118,12 +120,12 @@ const EditUser = ({ user }: EditUserProps) => {
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
label={t("user.fullName")}
>
<Input
id="name"
{...register("full_name")}
placeholder="Full name"
placeholder={t("user.fullName")}
type="text"
/>
</Field>
@ -131,17 +133,17 @@ const EditUser = ({ user }: EditUserProps) => {
<Field
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
label={t("user.setPassword")}
>
<Input
id="password"
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
message: t("forms.passwordMinLength"),
},
})}
placeholder="Password"
placeholder={t("auth.password")}
type="password"
/>
</Field>
@ -149,16 +151,16 @@ const EditUser = ({ user }: EditUserProps) => {
<Field
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
label={t("auth.confirmPassword")}
>
<Input
id="confirm_password"
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
t("forms.passwordsDoNotMatch"),
})}
placeholder="Password"
placeholder={t("auth.password")}
type="password"
/>
</Field>
@ -174,7 +176,7 @@ const EditUser = ({ user }: EditUserProps) => {
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
{t("user.isSuperuser")}
</Checkbox>
</Field>
)}
@ -188,7 +190,7 @@ const EditUser = ({ user }: EditUserProps) => {
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
{t("user.isActive")}
</Checkbox>
</Field>
)}
@ -203,11 +205,11 @@ const EditUser = ({ user }: EditUserProps) => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t("common.cancel")}
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
{t("common.save")}
</Button>
</DialogFooter>
<DialogCloseTrigger />

44
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 (
<MenuRoot>
<MenuTrigger asChild>
<Button variant="ghost" size="sm" gap={2}>
<FiGlobe />
<Text fontSize="sm">{currentLanguage}</Text>
</Button>
</MenuTrigger>
<MenuContent>
<MenuItem
value="en"
onClick={() => changeLanguage("en")}
style={{ cursor: "pointer" }}
>
{t("language.english")}
</MenuItem>
<MenuItem
value="zh"
onClick={() => changeLanguage("zh")}
style={{ cursor: "pointer" }}
>
{t("language.chinese")}
</MenuItem>
</MenuContent>
</MenuRoot>
);
};
export default LanguageSwitcher;

6
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 (
<Flex
@ -20,9 +23,10 @@ function Navbar() {
p={4}
>
<Link to="/">
<Image src={Logo} alt="Logo" maxW="3xs" p={2} />
<Image src={Logo} alt={t("common.logo")} maxW="3xs" p={2} />
</Link>
<Flex gap={2} alignItems="center">
<LanguageSwitcher />
<UserMenu />
</Flex>
</Flex>

11
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 (
<>
<Flex
@ -20,10 +23,10 @@ const NotFound = () => {
lineHeight="1"
mb={4}
>
404
{t('notFound.title')}
</Text>
<Text fontSize="2xl" fontWeight="bold" mb={2}>
Oops!
{t('notFound.subtitle')}
</Text>
</Flex>
</Flex>
@ -35,7 +38,7 @@ const NotFound = () => {
textAlign="center"
zIndex={1}
>
The page you are looking for was not found.
{t('notFound.description')}
</Text>
<Center zIndex={1}>
<Link to="/">
@ -45,7 +48,7 @@ const NotFound = () => {
mt={4}
alignSelf="center"
>
Go Back
{t('notFound.goHome')}
</Button>
</Link>
</Center>

8
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<UserPublic>(["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}
>
<FiLogOut />
<Text>Log Out</Text>
<Text>{t('navigation.logout')}</Text>
</Flex>
</Box>
{currentUser?.email && (
<Text fontSize="sm" p={2} truncate maxW="sm">
Logged in as: {currentUser.email}
{t('auth.email')}: {currentUser.email}
</Text>
)}
</Flex>

26
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<UserPublic>(["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 }) => (
<RouterLink key={title} to={path} onClick={onClose}>
const listItems = finalItems.map(({ icon, titleKey, path }) => (
<RouterLink key={titleKey} to={path} onClick={onClose}>
<Flex
gap={4}
px={4}
@ -43,7 +45,7 @@ const SidebarItems = ({ onClose }: SidebarItemsProps) => {
fontSize="sm"
>
<Icon as={icon} alignSelf="center" />
<Text ml={2}>{title}</Text>
<Text ml={2}>{t(titleKey)}</Text>
</Flex>
</RouterLink>
))
@ -51,7 +53,7 @@ const SidebarItems = ({ onClose }: SidebarItemsProps) => {
return (
<>
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
Menu
{t('navigation.menu')}
</Text>
<Box>{listItems}</Box>
</>

8
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 = () => {
<MenuTrigger asChild p={2}>
<Button data-testid="user-menu" variant="solid" maxW="sm" truncate>
<FaUserAstronaut fontSize="18" />
<Text>{user?.full_name || "User"}</Text>
<Text>{user?.full_name || t('user.profile')}</Text>
</Button>
</MenuTrigger>
@ -35,7 +37,7 @@ const UserMenu = () => {
style={{ cursor: "pointer" }}
>
<FiUser fontSize="18px" />
<Box flex="1">My Profile</Box>
<Box flex="1">{t('user.profile')}</Box>
</MenuItem>
</Link>
@ -47,7 +49,7 @@ const UserMenu = () => {
style={{ cursor: "pointer" }}
>
<FiLogOut />
Log Out
{t('navigation.logout')}
</MenuItem>
</MenuContent>
</MenuRoot>

24
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 = () => {
<DialogTrigger asChild>
<Button value="add-item" my={4}>
<FaPlus fontSize="16px" />
Add Item
{t('items.addItem')}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add Item</DialogTitle>
<DialogTitle>{t('items.addItem')}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Fill in the details to add a new item.</Text>
<Text mb={4}>{t('forms.fillDetails')}</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
label={t('items.title')}
>
<Input
id="title"
{...register("title", {
required: "Title is required.",
required: t('forms.titleRequired'),
})}
placeholder="Title"
placeholder={t('items.title')}
type="text"
/>
</Field>
@ -105,12 +107,12 @@ const AddItem = () => {
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
label={t('items.description')}
>
<Input
id="description"
{...register("description")}
placeholder="Description"
placeholder={t('items.description')}
type="text"
/>
</Field>
@ -124,7 +126,7 @@ const AddItem = () => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</Button>
</DialogActionTrigger>
<Button
@ -133,7 +135,7 @@ const AddItem = () => {
disabled={!isValid}
loading={isSubmitting}
>
Save
{t('common.save')}
</Button>
</DialogFooter>
</form>

17
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 }) => {
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete Item
{t('items.deleteItem')}
</Button>
</DialogTrigger>
@ -67,12 +69,11 @@ const DeleteItem = ({ id }: { id: string }) => {
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
<DialogTitle>{t('items.deleteItem')}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
This item will be permanently deleted. Are you sure? You will not
be able to undo this action.
{t('messages.confirmation.deleteItem')}
</Text>
</DialogBody>
@ -83,7 +84,7 @@ const DeleteItem = ({ id }: { id: string }) => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</Button>
</DialogActionTrigger>
<Button
@ -92,7 +93,7 @@ const DeleteItem = ({ id }: { id: string }) => {
type="submit"
loading={isSubmitting}
>
Delete
{t('common.delete')}
</Button>
</DialogFooter>
</form>

24
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) => {
<DialogTrigger asChild>
<Button variant="ghost">
<FaExchangeAlt fontSize="16px" />
Edit Item
{t('items.editItem')}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Item</DialogTitle>
<DialogTitle>{t('items.editItem')}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the item details below.</Text>
<Text mb={4}>{t('forms.updateDetails')}</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
label={t('items.title')}
>
<Input
id="title"
{...register("title", {
required: "Title is required",
required: t('forms.titleRequired'),
})}
placeholder="Title"
placeholder={t('items.title')}
type="text"
/>
</Field>
@ -113,12 +115,12 @@ const EditItem = ({ item }: EditItemProps) => {
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
label={t('items.description')}
>
<Input
id="description"
{...register("description")}
placeholder="Description"
placeholder={t('items.description')}
type="text"
/>
</Field>
@ -133,11 +135,11 @@ const EditItem = ({ item }: EditItemProps) => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
{t('common.save')}
</Button>
</ButtonGroup>
</DialogFooter>

63
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 = () => (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="sm">ID</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Title</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Description</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
const PendingItems = () => {
const { t } = useTranslation()
return (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="sm">{t("common.id")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.title")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.description")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.actions")}</Table.ColumnHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
<Table.Cell>
<SkeletonText noOfLines={1} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
}
export default PendingItems

10
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 (
<>
<Container maxW="full">
<Heading size="sm" py={4}>
Appearance
{t("user.appearance")}
</Heading>
<RadioGroup
@ -19,9 +21,9 @@ const Appearance = () => {
colorPalette="teal"
>
<Stack>
<Radio value="system">System</Radio>
<Radio value="light">Light Mode</Radio>
<Radio value="dark">Dark Mode</Radio>
<Radio value="system">{t("theme.system")}</Radio>
<Radio value="light">{t("theme.light")}</Radio>
<Radio value="dark">{t("theme.dark")}</Radio>
</Stack>
</RadioGroup>
</Container>

20
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 = () => {
<>
<Container maxW="full">
<Heading size="sm" py={4}>
Change Password
{t("user.changePassword")}
</Heading>
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
<VStack gap={4} w={{ base: "100%", md: "sm" }}>
<PasswordInput
type="current_password"
startElement={<FiLock />}
{...register("current_password", passwordRules())}
placeholder="Current Password"
{...register("current_password", passwordRules(t))}
placeholder={t("auth.currentPassword")}
errors={errors}
/>
<PasswordInput
type="new_password"
startElement={<FiLock />}
{...register("new_password", passwordRules())}
placeholder="New Password"
{...register("new_password", passwordRules(t))}
placeholder={t("auth.newPassword")}
errors={errors}
/>
<PasswordInput
type="confirm_password"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
{...register("confirm_password", confirmPasswordRules(getValues, t))}
placeholder={t("auth.confirmPassword")}
errors={errors}
/>
</VStack>
@ -78,7 +80,7 @@ const ChangePassword = () => {
loading={isSubmitting}
disabled={!isValid}
>
Save
{t("common.save")}
</Button>
</Box>
</Container>

8
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 (
<Container maxW="full">
<Heading size="sm" py={4}>
Delete Account
{t("user.deleteAccount")}
</Heading>
<Text>
Permanently delete your data and everything associated with your
account.
{t("messages.confirmation.deleteAccount")}
</Text>
<DeleteConfirmation />
</Container>

17
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 = () => {
>
<DialogTrigger asChild>
<Button variant="solid" colorPalette="red" mt={4}>
Delete
{t("common.delete")}
</Button>
</DialogTrigger>
@ -67,14 +69,11 @@ const DeleteConfirmation = () => {
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Confirmation Required</DialogTitle>
<DialogTitle>{t("messages.confirmation.confirmationRequired")}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot
be undone.
{t("messages.confirmation.deleteAccount")}
</Text>
</DialogBody>
@ -86,7 +85,7 @@ const DeleteConfirmation = () => {
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
{t("common.cancel")}
</Button>
</DialogActionTrigger>
<Button
@ -95,7 +94,7 @@ const DeleteConfirmation = () => {
type="submit"
loading={isSubmitting}
>
Delete
{t("common.delete")}
</Button>
</ButtonGroup>
</DialogFooter>

16
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 = () => {
<>
<Container maxW="full">
<Heading size="sm" py={4}>
User Information
{t("user.userInformation")}
</Heading>
<Box
w={{ sm: "full", md: "sm" }}
as="form"
onSubmit={handleSubmit(onSubmit)}
>
<Field label="Full name">
<Field label={t("user.fullName")}>
{editMode ? (
<Input
{...register("full_name", { maxLength: 30 })}
@ -101,14 +103,14 @@ const UserInformation = () => {
</Field>
<Field
mt={4}
label="Email"
label={t("user.email")}
invalid={!!errors.email}
errorText={errors.email?.message}
>
{editMode ? (
<Input
{...register("email", {
required: "Email is required",
required: t("forms.emailRequired"),
pattern: emailPattern,
})}
type="email"
@ -128,7 +130,7 @@ const UserInformation = () => {
loading={editMode ? isSubmitting : false}
disabled={editMode ? !isDirty || !getValues("email") : false}
>
{editMode ? "Save" : "Edit"}
{editMode ? t("common.save") : t("common.edit")}
</Button>
{editMode && (
<Button
@ -137,7 +139,7 @@ const UserInformation = () => {
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
{t("common.cancel")}
</Button>
)}
</Flex>

4
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 (
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
<ChakraIconButton variant="ghost" aria-label={t("common.close")} ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
)

4
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 (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
aria-label={t("theme.toggleColorMode")}
size="sm"
ref={ref}
{...props}

13
frontend/src/components/ui/password-input.tsx

@ -16,6 +16,7 @@ import {
useControllableState,
} from "@chakra-ui/react"
import { forwardRef, useRef } from "react"
import { useTranslation } from "react-i18next"
import { FiEye, FiEyeOff } from "react-icons/fi"
import { Field } from "./field"
import { InputGroup } from "./input-group"
@ -95,6 +96,8 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
const { t } = useTranslation()
return (
<IconButton
tabIndex={-1}
@ -104,7 +107,7 @@ const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
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" }
}
}

7
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",
})

35
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

193
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"
}
}

199
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": "切换颜色模式"
}
}

1
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 () => {

24
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<UserPublic>(["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() {
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="sm">Full name</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Email</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Role</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Status</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("user.fullName")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("user.email")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("user.role")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("user.status")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.actions")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{users?.map((user) => (
<Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Table.Cell color={!user.full_name ? "gray" : "inherit"}>
{user.full_name || "N/A"}
{user.full_name || t("common.notAvailable")}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
{t("user.you")}
</Badge>
)}
</Table.Cell>
@ -83,9 +85,9 @@ function UsersTable() {
{user.email}
</Table.Cell>
<Table.Cell>
{user.is_superuser ? "Superuser" : "User"}
{user.is_superuser ? t("user.superuser") : t("user.user")}
</Table.Cell>
<Table.Cell>{user.is_active ? "Active" : "Inactive"}</Table.Cell>
<Table.Cell>{user.is_active ? t("user.active") : t("user.inactive")}</Table.Cell>
<Table.Cell>
<UserActionsMenu
user={user}
@ -114,10 +116,12 @@ function UsersTable() {
}
function Admin() {
const { t } = useTranslation()
return (
<Container maxW="full">
<Heading size="lg" pt={12}>
Users Management
{t("user.usersManagement")}
</Heading>
<AddUser />

6
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 (
<>
<Container maxW="full">
<Box pt={12} m={4}>
<Text fontSize="2xl" truncate maxW="sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
{t("dashboard.greeting", { name: currentUser?.full_name || currentUser?.email })} 👋🏼
</Text>
<Text>Welcome back, nice to see you again!</Text>
<Text>{t("dashboard.welcomeBack")}</Text>
</Box>
</Container>
</>

20
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() {
<FiSearch />
</EmptyState.Indicator>
<VStack textAlign="center">
<EmptyState.Title>You don't have any items yet</EmptyState.Title>
<EmptyState.Title>{t("items.noItemsYet")}</EmptyState.Title>
<EmptyState.Description>
Add a new item to get started
{t("items.addNewItemToStart")}
</EmptyState.Description>
</VStack>
</EmptyState.Content>
@ -85,10 +87,10 @@ function ItemsTable() {
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="sm">ID</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Title</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Description</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.id")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("items.title")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("items.description")}</Table.ColumnHeader>
<Table.ColumnHeader w="sm">{t("common.actions")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
@ -105,7 +107,7 @@ function ItemsTable() {
truncate
maxW="30%"
>
{item.description || "N/A"}
{item.description || t("common.notAvailable")}
</Table.Cell>
<Table.Cell>
<ItemActionsMenu item={item} />
@ -132,10 +134,12 @@ function ItemsTable() {
}
function Items() {
const { t } = useTranslation()
return (
<Container maxW="full">
<Heading size="lg" pt={12}>
Items Management
{t("items.itemsManagement")}
</Heading>
<AddItem />
<ItemsTable />

27
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 (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
User Settings
{t("user.userSettings")}
</Heading>
<Tabs.Root defaultValue="my-profile" variant="subtle">
@ -51,3 +50,7 @@ function UserSettings() {
</Container>
)
}
export const Route = createFileRoute("/_layout/settings")({
component: UserSettings,
})

20
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() {
>
<Image
src={Logo}
alt="FastAPI logo"
alt={t("common.logo")}
height="auto"
maxW="2xs"
alignSelf="center"
@ -82,10 +84,10 @@ function Login() {
<Input
id="username"
{...register("username", {
required: "Username is required",
required: t("forms.usernameRequired"),
pattern: emailPattern,
})}
placeholder="Email"
placeholder={t("user.email")}
type="email"
/>
</InputGroup>
@ -93,20 +95,20 @@ function Login() {
<PasswordInput
type="password"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password"
{...register("password", passwordRules(t))}
placeholder={t("auth.password")}
errors={errors}
/>
<RouterLink to="/recover-password" className="main-link">
Forgot Password?
{t("auth.forgotPassword")}
</RouterLink>
<Button variant="solid" type="submit" loading={isSubmitting} size="md">
Log In
{t("auth.logIn")}
</Button>
<Text>
Don't have an account?{" "}
{t("auth.noAccount")}{" "}
<RouterLink to="/signup" className="main-link">
Sign Up
{t("auth.signUp")}
</RouterLink>
</Text>
</Container>

14
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
>
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Password Recovery
{t("auth.passwordRecovery")}
</Heading>
<Text textAlign="center">
A password recovery email will be sent to the registered account.
{t("auth.passwordRecoveryDescription")}
</Text>
<Field invalid={!!errors.email} errorText={errors.email?.message}>
<InputGroup w="100%" startElement={<FiMail />}>
<Input
id="email"
{...register("email", {
required: "Email is required",
required: t("forms.emailRequired"),
pattern: emailPattern,
})}
placeholder="Email"
placeholder={t("user.email")}
type="email"
/>
</InputGroup>
</Field>
<Button variant="solid" type="submit" loading={isSubmitting}>
Continue
{t("common.continue")}
</Button>
</Container>
)

18
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
>
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Reset Password
{t("auth.resetPassword")}
</Heading>
<Text textAlign="center">
Please enter your new password and confirm it to reset your password.
{t("auth.resetPasswordDescription")}
</Text>
<PasswordInput
startElement={<FiLock />}
type="new_password"
errors={errors}
{...register("new_password", passwordRules())}
placeholder="New Password"
{...register("new_password", passwordRules(t))}
placeholder={t("auth.newPassword")}
/>
<PasswordInput
startElement={<FiLock />}
type="confirm_password"
errors={errors}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
{...register("confirm_password", confirmPasswordRules(getValues, t))}
placeholder={t("auth.confirmPassword")}
/>
<Button variant="solid" type="submit">
Reset Password
{t("auth.resetPassword")}
</Button>
</Container>
)

26
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() {
>
<Image
src={Logo}
alt="FastAPI logo"
alt={t("common.logo")}
height="auto"
maxW="2xs"
alignSelf="center"
@ -83,9 +85,9 @@ function SignUp() {
id="full_name"
minLength={3}
{...register("full_name", {
required: "Full Name is required",
required: t("forms.fullNameRequired"),
})}
placeholder="Full Name"
placeholder={t("user.fullName")}
type="text"
/>
</InputGroup>
@ -96,10 +98,10 @@ function SignUp() {
<Input
id="email"
{...register("email", {
required: "Email is required",
required: t("forms.emailRequired"),
pattern: emailPattern,
})}
placeholder="Email"
placeholder={t("user.email")}
type="email"
/>
</InputGroup>
@ -107,24 +109,24 @@ function SignUp() {
<PasswordInput
type="password"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password"
{...register("password", passwordRules(t))}
placeholder={t("auth.password")}
errors={errors}
/>
<PasswordInput
type="confirm_password"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
{...register("confirm_password", confirmPasswordRules(getValues, t))}
placeholder={t("auth.confirmPassword")}
errors={errors}
/>
<Button variant="solid" type="submit" loading={isSubmitting}>
Sign Up
{t("auth.signUp")}
</Button>
<Text>
Already have an account?{" "}
{t("auth.haveAccount")}{" "}
<RouterLink to="/login" className="main-link">
Log In
{t("auth.logIn")}
</RouterLink>
</Text>
</Container>

15
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
}

Loading…
Cancel
Save