From 2bf7f2d7108fdce47ca7f5540c58e53f580d030e Mon Sep 17 00:00:00 2001 From: Jing Hua Date: Sun, 12 Mar 2023 01:40:26 +0800 Subject: [PATCH] feat: i18n language support, settings menu, loading screen Fixes #7 --- README.md | 8 +- package.json | 4 + public/locales/en/about.json | 22 +++++ public/locales/en/api.json | 13 +++ public/locales/en/main.json | 25 ++++++ public/locales/en/model.json | 13 +++ public/locales/zh-CN/about.json | 22 +++++ public/locales/zh-CN/api.json | 13 +++ public/locales/zh-CN/main.json | 25 ++++++ public/locales/zh-CN/model.json | 13 +++ src/App.tsx | 17 ++-- src/assets/icons/SettingIcon.tsx | 9 +- src/assets/icons/SpinnerIcon.tsx | 24 +++++ src/components/AboutMenu/AboutMenu.tsx | 89 +++++++++++++++---- src/components/ApiMenu/ApiMenu.tsx | 58 ++++++------ src/components/Chat/ChatContent/ChatTitle.tsx | 8 +- .../Chat/ChatContent/DownloadChat.tsx | 4 +- .../ChatContent/Message/MessageContent.tsx | 21 +++-- .../Chat/ChatContent/Message/RoleSelector.tsx | 6 +- src/components/ConfigMenu/ConfigMenu.tsx | 16 ++-- .../ImportExportChat/ImportExportChat.tsx | 16 ++-- .../LanguageSelector/LanguageSelector.tsx | 49 ++++++++++ src/components/LanguageSelector/index.ts | 1 + .../LoadingScreen/LoadingScreen.tsx | 12 +++ src/components/LoadingScreen/index.ts | 1 + src/components/Menu/MenuOptions/Api.tsx | 4 +- .../Menu/MenuOptions/ClearConversation.tsx | 9 +- src/components/Menu/MenuOptions/Me.tsx | 4 +- .../Menu/MenuOptions/MenuOptions.tsx | 9 +- .../Menu/MenuOptions/ThemeSwitcher.tsx | 12 +-- src/components/Menu/NewChat.tsx | 6 +- src/components/PopupModal/PopupModal.tsx | 6 +- src/components/SettingsMenu/SettingsMenu.tsx | 45 ++++++++++ src/components/SettingsMenu/index.ts | 1 + src/i18n.ts | 16 ++++ src/main.tsx | 2 + tsconfig.json | 3 +- vite.config.ts | 1 + yarn.lock | 75 +++++++++++++++- 39 files changed, 578 insertions(+), 104 deletions(-) create mode 100644 public/locales/en/about.json create mode 100644 public/locales/en/api.json create mode 100644 public/locales/en/main.json create mode 100644 public/locales/en/model.json create mode 100644 public/locales/zh-CN/about.json create mode 100644 public/locales/zh-CN/api.json create mode 100644 public/locales/zh-CN/main.json create mode 100644 public/locales/zh-CN/model.json create mode 100644 src/assets/icons/SpinnerIcon.tsx create mode 100644 src/components/LanguageSelector/LanguageSelector.tsx create mode 100644 src/components/LanguageSelector/index.ts create mode 100644 src/components/LoadingScreen/LoadingScreen.tsx create mode 100644 src/components/LoadingScreen/index.ts create mode 100644 src/components/SettingsMenu/SettingsMenu.tsx create mode 100644 src/components/SettingsMenu/index.ts create mode 100644 src/i18n.ts diff --git a/README.md b/README.md index cc1a555..181d830 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,13 @@ At `ChatGPTFreeApp`, we strive to provide you with useful and amazing features a If you have enjoyed using our app, we kindly ask you to give this project a ⭐️. Your endorsement means a lot to us and encourages us to work harder towards delivering the best possible experience. -If you would like to support the team, consider buying us a coffee by clicking on the button below. Every contribution, no matter how small, helps us to maintain and improve our service. +If you would like to support the team, consider sponsoring us through one of the methods below. Every contribution, no matter how small, helps us to maintain and improve our service. -[![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/freechatgpt) +| Payment Method | Link | +| -------------- | -------------------------------------------------------------------------------------- | +| KoFi | [![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/freechatgpt) | +| Alipay (Ayaka) | | +| Wechat (Ayaka) | | Thank you for being a part of our community, and we look forward to serving you better in the future. diff --git a/package.json b/package.json index 5afa853..bc99f34 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,13 @@ }, "dependencies": { "html2canvas": "^1.4.1", + "i18next": "^22.4.11", + "i18next-browser-languagedetector": "^7.0.1", + "i18next-http-backend": "^2.1.1", "jspdf": "^2.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^12.2.0", "react-markdown": "^8.0.5", "react-scroll-to-bottom": "^4.2.0", "rehype-highlight": "^6.0.0", diff --git a/public/locales/en/about.json b/public/locales/en/about.json new file mode 100644 index 0000000..4d9d745 --- /dev/null +++ b/public/locales/en/about.json @@ -0,0 +1,22 @@ +{ + "description": "Free ChatGPT is an amazing open-source web app that allows you to play with OpenAI's ChatGPT API for free!", + "discordServer": { + "title": "Discord Server", + "paragraph1": "We invite you to join our Discord community! Our Discord server is a great place to exchange ChatGPT ideas and tips, and submit feature requests for Free ChatGPT. You'll have the opportunity to interact with the developers behind Free ChatGPT as well as other AI enthusiasts who share your passion.", + "paragraph2": "To join our server, simply click on the following link: <0>https://discord.gg/g3Qnwy4V6A. We can't wait to see you there!" + }, + "privacyStatement": { + "title": "Privacy Statement", + "paragraph1": "We highly value your privacy and are committed to safeguarding the privacy of our users. We do not collect or store any text you enter or receive from the OpenAI server in any form. Our source code is available for your inspection to verify this statement.", + "paragraph2": "We prioritise the security of your API key and handle it with utmost care. If you use your own API key, your key is exclusively stored on your browser and never shared with any third-party entity. It is solely used for the intended purpose of accessing the OpenAI API and not for any other unauthorised use." + }, + "support": { + "title": "Support", + "paragraph1": "At Free ChatGPT, we strive to provide you with useful and amazing features around the clock. And just like any project, your support and motivation will be instrumental in helping us keep moving forward!", + "paragraph2": "If you have enjoyed using our app, we kindly ask you to give this <0>project a ⭐️. Your endorsement means a lot to us and encourages us to work harder towards delivering the best possible experience.", + "paragraph3": "If you would like to support the team, consider sponsoring us through one of the methods below. Every contribution, no matter how small, helps us to maintain and improve our service.", + "paragraph4": "Thank you for being a part of our community, and we look forward to serving you better in the future.", + "alipay": "Alipay", + "wechatPay": "WeChat Pay" + } +} diff --git a/public/locales/en/api.json b/public/locales/en/api.json new file mode 100644 index 0000000..778757c --- /dev/null +++ b/public/locales/en/api.json @@ -0,0 +1,13 @@ +{ + "securityMessage": "We prioritise the security of your API key and handle it with utmost care. Your key is exclusively stored on your browser and never shared with any third-party entity. It is solely used for the intended purpose of accessing the OpenAI API and not for any other unauthorised use.", + "apiEndpoint": { + "option": "Use free API endpoint", + "inputLabel": "Free API Endpoint", + "description": "Use free API endpoint from <0>Ayaka: https://chatgpt-api.shn.hk/v1/ or enter your own API endpoint" + }, + "apiKey": { + "option": "Use your own API key", + "howTo": "Get your personal API key <0>here", + "inputLabel": "API Key" + } +} diff --git a/public/locales/en/main.json b/public/locales/en/main.json new file mode 100644 index 0000000..6c47095 --- /dev/null +++ b/public/locales/en/main.json @@ -0,0 +1,25 @@ +{ + "save": "Save", + "saveAndSubmit": "Save & Submit", + "cancel": "Cancel", + "confirm": "Confirm", + "warning": "Warning", + "clearMessageWarning": "Please be advised that by submitting this message, all subsequent messages will be deleted!", + "clearConversationWarning": "Please be advised that by confirming this action, all messages will be deleted!", + "clearConversation": "Clear Conversation", + "import": "Import", + "export": "Export", + "author": "Made by Jing Hua", + "about": "About", + "api": "API", + "personal": "Personal", + "free": "Free", + "downloadChat": "Download Chat", + "user": "User", + "assistant": "Assistant", + "system": "System", + "newChat": "New Chat", + "lightMode": "Light Mode", + "darkMode": "Dark Mode", + "setting": "Settings" +} diff --git a/public/locales/en/model.json b/public/locales/en/model.json new file mode 100644 index 0000000..2dd3d04 --- /dev/null +++ b/public/locales/en/model.json @@ -0,0 +1,13 @@ +{ + "configuration": "Configuration", + "model": "Model", + "default": "Default", + "temperature": { + "label": "Temperature", + "description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic (Default: 1)" + }, + "presencePenalty": { + "label": "Presence Penalty", + "description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. (Default: 0)" + } +} diff --git a/public/locales/zh-CN/about.json b/public/locales/zh-CN/about.json new file mode 100644 index 0000000..e4993e9 --- /dev/null +++ b/public/locales/zh-CN/about.json @@ -0,0 +1,22 @@ +{ + "description": "Free ChatGPT 是一个神奇的开源 Web 应用,允许您免费使用 OpenAI 的 ChatGPT API 进行对话!", + "discordServer": { + "title": "Discord 服务器", + "paragraph1": "我们邀请您加入我们的 Discord 社区!我们的 Discord 服务器是一个很好的地方,可以交流 ChatGPT 的想法和技巧,并提交 Free ChatGPT 的功能请求。您将有机会与 Free ChatGPT 的开发人员以及其他分享您热情的人工智能爱好者互动。", + "paragraph2": "要加入我们的服务器,只需单击以下链接:<0>https://discord.gg/g3Qnwy4V6A。我们迫不及待地想见到您!" + }, + "privacyStatement": { + "title": "隐私声明", + "paragraph1": "我们非常重视您的隐私,并致力于保护用户的隐私。我们不会以任何形式收集或存储您输入或从 OpenAI 服务器接收的任何文本。我们的源代码可以供您检查,以验证此声明。", + "paragraph2": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。如果您使用自己的 API 密钥,您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不会用于任何其他未经授权的用途。" + }, + "support": { + "title": "支持", + "paragraph1": "在 Free ChatGPT,我们致力于为您提供实用和惊人的功能。就像任何项目一样,您的支持和激励将对我们在保持前进方面起到至关重要的作用!", + "paragraph2": "如果您喜欢使用我们的应用程序,我们恳请您给这个<0>项目一个 ⭐️。您的认可对我们意义重大,鼓励我们更加努力,以提供最佳的体验。", + "paragraph3": "如果您想支持我们的团队,请考虑通过以下方法之一赞助我们。每一份贡献,无论多小,都有助于我们维护和改善我们的服务。", + "paragraph4": "感谢您成为我们社区的一员,我们期待着在未来为您提供更好的服务。", + "alipay": "支付宝", + "wechatPay": "微信支付" + } +} diff --git a/public/locales/zh-CN/api.json b/public/locales/zh-CN/api.json new file mode 100644 index 0000000..ade8390 --- /dev/null +++ b/public/locales/zh-CN/api.json @@ -0,0 +1,13 @@ +{ + "securityMessage": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不是用于任何其他未经授权的用途。", + "apiEndpoint": { + "option": "使用免费的 API 端点", + "inputLabel": "免费的 API 端点", + "description": "使用 <0>Ayaka 提供的免费 API 端点:https://chatgpt-api.shn.hk/v1/,或输入您自己的 API 端点" + }, + "apiKey": { + "option": "使用自己的 API 密钥", + "howTo": "在<0>这里获取您的个人 API 密钥", + "inputLabel": "API 密钥" + } +} diff --git a/public/locales/zh-CN/main.json b/public/locales/zh-CN/main.json new file mode 100644 index 0000000..fb4b124 --- /dev/null +++ b/public/locales/zh-CN/main.json @@ -0,0 +1,25 @@ +{ + "save": "保存", + "saveAndSubmit": "保存并提交", + "cancel": "取消", + "confirm": "确认", + "warning": "警告", + "clearMessageWarning": "请注意,通过提交此消息,所有后续消息都将被删除!", + "clearConversationWarning": "请注意,确认此操作将删除所有消息!", + "clearConversation": "清除对话", + "import": "导入", + "export": "导出", + "author": "由 Jing Hua 制作", + "about": "关于", + "api": "API", + "personal": "个人", + "free": "免费", + "downloadChat": "下载聊天记录", + "user": "用户", + "assistant": "助手", + "system": "系统", + "newChat": "新聊天", + "lightMode": "亮色模式", + "darkMode": "黑暗模式", + "setting": "设置" +} diff --git a/public/locales/zh-CN/model.json b/public/locales/zh-CN/model.json new file mode 100644 index 0000000..51ce4af --- /dev/null +++ b/public/locales/zh-CN/model.json @@ -0,0 +1,13 @@ +{ + "configuration": "配置", + "model": "模型", + "default": "默认", + "temperature": { + "label": "采样温度", + "description": "使用何种采样温度,值在 0 到 2 之间。较高的数值如 0.8 会使输出更加随机,而较低的数值如 0.2 会使输出更加集中和确定。(默认: 1)" + }, + "presencePenalty": { + "label": "存在惩罚", + "description": "数值在 -2.0 到 2.0 之间。正值会根据新 token 是否已经出现在文本中来惩罚它们,增加模型谈论新话题的可能性。 (默认: 0)" + } +} diff --git a/src/App.tsx b/src/App.tsx index a948f4c..630b7e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, Suspense } from 'react'; import useStore from '@store/store'; -import Chat from './components/Chat'; -import Menu from './components/Menu'; +import Chat from '@components/Chat'; +import Menu from '@components/Menu'; +import LoadingScreen from '@components/LoadingScreen'; import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; import { ChatInterface } from '@type/chat'; @@ -65,10 +66,12 @@ function App() { }, []); return ( -
- - -
+ }> +
+ + +
+
); } diff --git a/src/assets/icons/SettingIcon.tsx b/src/assets/icons/SettingIcon.tsx index cfbf610..46e2715 100644 --- a/src/assets/icons/SettingIcon.tsx +++ b/src/assets/icons/SettingIcon.tsx @@ -1,8 +1,13 @@ import React from 'react'; -const SettingIcon = () => { +const SettingIcon = (props: React.SVGProps) => { return ( - + ); diff --git a/src/assets/icons/SpinnerIcon.tsx b/src/assets/icons/SpinnerIcon.tsx new file mode 100644 index 0000000..d016078 --- /dev/null +++ b/src/assets/icons/SpinnerIcon.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const SpinnerIcon = (props: React.SVGProps) => { + return ( + + ); +}; + +export default SpinnerIcon; diff --git a/src/components/AboutMenu/AboutMenu.tsx b/src/components/AboutMenu/AboutMenu.tsx index 2331234..e688498 100644 --- a/src/components/AboutMenu/AboutMenu.tsx +++ b/src/components/AboutMenu/AboutMenu.tsx @@ -1,9 +1,11 @@ import React, { useState } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; import PopupModal from '@components/PopupModal'; import AboutIcon from '@icon/AboutIcon'; import Updates from '@components/Menu/MenuOptions/Updates'; const AboutMenu = () => { + const { t } = useTranslation(['main', 'about']); const [isModalOpen, setIsModalOpen] = useState(false); return ( @@ -17,7 +19,7 @@ const AboutMenu = () => {
- About + {t('about')} {isModalOpen && ( { >
-

Free ChatGPT is an amazing open-source web app that allows you to play with OpenAI's ChatGPT API for free!

+

{t('description', { ns: 'about' })}

-

Discord Server

-

We invite you to join our Discord community! Our Discord server is a great place to exchange ChatGPT ideas and tips, and submit feature requests for Free ChatGPT. You'll have the opportunity to interact with the developers behind Free ChatGPT as well as other AI enthusiasts who share your passion.

+

+ {t('discordServer.title', { ns: 'about' })} +

+

{t('discordServer.paragraph1', { ns: 'about' })}

-

To join our server, simply click on the following link: https://discord.gg/g3Qnwy4V6A. We can't wait to see you there!

+

+ , + ]} + /> +

-

Privacy Statement

-

We highly value your privacy and are committed to safeguarding the privacy of our users. We do not collect or store any text you enter or receive from the OpenAI server in any form. Our source code is available for your inspection to verify this statement.

+

+ {t('privacyStatement.title', { ns: 'about' })} +

+

{t('privacyStatement.paragraph1', { ns: 'about' })}

-

We prioritise the security of your API key and handle it with utmost care. If you use your own API key, your key is exclusively stored on your browser and never shared with any third-party entity. It is solely used for the intended purpose of accessing the OpenAI API and not for any other unauthorised use.

-

Support

-

At freechatgpt.chat, we strive to provide you with useful and amazing features around the clock. And just like any project, your support and motivation will be instrumental in helping us keep moving forward!

-

If you have enjoyed using our app, we kindly ask you to give this project a ⭐️. Your endorsement means a lot to us and encourages us to work harder towards delivering the best possible experience.

-

If you would like to support the team, consider buying us a coffee by clicking on the button below. Every contribution, no matter how small, helps us to maintain and improve our service.

- - Support us through the Ko-fi platform. - -

Thank you for being a part of our community, and we look forward to serving you better in the future.

+

{t('privacyStatement.paragraph2', { ns: 'about' })}

+

+ {t('support.title', { ns: 'about' })} +

+

{t('support.paragraph1', { ns: 'about' })}

+

+ , + ]} + /> +

+

{t('support.paragraph3', { ns: 'about' })}

+
+ + Support us through the Ko-fi platform. + +
+
+
{t('support.alipay', { ns: 'about' })} (Ayaka)
+ Support us through Alipay +
+
+
{t('support.wechatPay', { ns: 'about' })} (Ayaka)
+ Support us through WeChat Pay +
+
+
+

+ {t('support.paragraph4', { ns: 'about' })} +

diff --git a/src/components/ApiMenu/ApiMenu.tsx b/src/components/ApiMenu/ApiMenu.tsx index 15dc267..df0539f 100644 --- a/src/components/ApiMenu/ApiMenu.tsx +++ b/src/components/ApiMenu/ApiMenu.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; import useStore from '@store/store'; import PopupModal from '@components/PopupModal'; @@ -10,6 +11,8 @@ const ApiMenu = ({ isModalOpen: boolean; setIsModalOpen: React.Dispatch>; }) => { + const { t } = useTranslation(['main', 'api']); + const apiKey = useStore((state) => state.apiKey); const setApiKey = useStore((state) => state.setApiKey); const apiFree = useStore((state) => state.apiFree); @@ -50,7 +53,7 @@ const ApiMenu = ({ return isModalOpen ? ( _setApiFree(true)} /> {_apiFree && (
- Use free API endpoint from{' '} - - Ayaka - - : https://chatgpt-api.shn.hk/v1/ or enter your own API endpoint + , + ]} + />
- Free API Endpoint + {t('apiEndpoint.inputLabel', { ns: 'api' })}
_setApiFree(false)} />
{_apiFree === false && (
- API Key + {t('apiEndpoint.inputLabel', { ns: 'api' })}
- Get your personal API key{' '} - - here - + , + ]} + />
- We prioritise the security of your API key and handle it with utmost - care. Your key is exclusively stored on your browser and never shared - with any third-party entity. It is solely used for the intended - purpose of accessing the OpenAI API and not for any other unauthorised - use. + {t('securityMessage', { ns: 'api' })}
diff --git a/src/components/Chat/ChatContent/ChatTitle.tsx b/src/components/Chat/ChatContent/ChatTitle.tsx index 96c3c85..3c760d2 100644 --- a/src/components/Chat/ChatContent/ChatTitle.tsx +++ b/src/components/Chat/ChatContent/ChatTitle.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { shallow } from 'zustand/shallow'; import useStore from '@store/store'; import ConfigMenu from '@components/ConfigMenu'; @@ -6,6 +7,7 @@ import { ChatInterface, ConfigInterface } from '@type/chat'; import { defaultChatConfig } from '@constants/chat'; const ChatTitle = React.memo(() => { + const { t } = useTranslation('model'); const config = useStore( (state) => state.chats && @@ -47,13 +49,13 @@ const ChatTitle = React.memo(() => { }} >
- Model: Default + {t('model')}: {t('default')}
- Temperature: {config.temperature} + {t('temperature.label')}: {config.temperature}
- PresencePenalty: {config.presence_penalty} + {t('presencePenalty.label')}: {config.presence_penalty}
{isModalOpen && ( diff --git a/src/components/Chat/ChatContent/DownloadChat.tsx b/src/components/Chat/ChatContent/DownloadChat.tsx index 60d6a7f..cda4829 100644 --- a/src/components/Chat/ChatContent/DownloadChat.tsx +++ b/src/components/Chat/ChatContent/DownloadChat.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import PopupModal from '@components/PopupModal'; import { @@ -14,6 +15,7 @@ import MarkdownIcon from '@icon/MarkdownIcon'; const DownloadChat = React.memo( ({ saveRef }: { saveRef: React.RefObject }) => { + const { t } = useTranslation(); const [isModalOpen, setIsModalOpen] = useState(false); return ( <> @@ -23,7 +25,7 @@ const DownloadChat = React.memo( setIsModalOpen(true); }} > - Download Chat + {t('downloadChat')} {isModalOpen && ( (false); const textareaRef = React.createRef(); + const { t } = useTranslation(); + const resetTextAreaHeight = () => { if (textareaRef.current) textareaRef.current.style.height = 'auto'; }; @@ -403,8 +406,8 @@ const EditView = ({ {isModalOpen && ( )} @@ -426,6 +429,8 @@ const EditViewButtons = React.memo( setIsModalOpen: React.Dispatch>; setIsEdit: React.Dispatch>; }) => { + const { t } = useTranslation(); + return (
{sticky && ( @@ -434,7 +439,7 @@ const EditViewButtons = React.memo( onClick={handleSaveAndSubmit} >
- Save & Submit + {t('saveAndSubmit')}
)} @@ -445,7 +450,9 @@ const EditViewButtons = React.memo( }`} onClick={handleSave} > -
Save
+
+ {t('save')} +
{sticky || ( @@ -456,7 +463,7 @@ const EditViewButtons = React.memo( }} >
- Save & Submit + {t('saveAndSubmit')}
)} @@ -466,7 +473,9 @@ const EditViewButtons = React.memo( className='btn relative btn-neutral' onClick={() => setIsEdit(false)} > -
Cancel
+
+ {t('cancel')} +
)}
diff --git a/src/components/Chat/ChatContent/Message/RoleSelector.tsx b/src/components/Chat/ChatContent/Message/RoleSelector.tsx index b127e45..24b63d9 100644 --- a/src/components/Chat/ChatContent/Message/RoleSelector.tsx +++ b/src/components/Chat/ChatContent/Message/RoleSelector.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import DownChevronArrow from '@icon/DownChevronArrow'; @@ -14,6 +15,7 @@ const RoleSelector = React.memo( messageIndex: number; sticky?: boolean; }) => { + const { t } = useTranslation(); const setInputRole = useStore((state) => state.setInputRole); const setChats = useStore((state) => state.setChats); const currentChatIndex = useStore((state) => state.currentChatIndex); @@ -27,7 +29,7 @@ const RoleSelector = React.memo( type='button' onClick={() => setDropDown((prev) => !prev)} > - {role.charAt(0).toUpperCase() + role.slice(1)} + {t(role)}
- {r.charAt(0).toUpperCase() + r.slice(1)} + {t(r)} ))} diff --git a/src/components/ConfigMenu/ConfigMenu.tsx b/src/components/ConfigMenu/ConfigMenu.tsx index f1e2628..d73b1db 100644 --- a/src/components/ConfigMenu/ConfigMenu.tsx +++ b/src/components/ConfigMenu/ConfigMenu.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import PopupModal from '@components/PopupModal'; import { ConfigInterface } from '@type/chat'; @@ -15,6 +16,7 @@ const ConfigMenu = ({ const [_presencePenalty, _setPresencePenalty] = useState( config.presence_penalty ); + const { t } = useTranslation('model'); const handleConfirm = () => { setConfig({ @@ -26,14 +28,14 @@ const ConfigMenu = ({ return (
- What sampling temperature to use, between 0 and 2. Higher values - like 0.8 will make the output more random, while lower values like - 0.2 will make it more focused and deterministic. + {t('temperature.description')}
- Number between -2.0 and 2.0. Positive values penalize new tokens - based on whether they appear in the text so far, increasing the - model's likelihood to talk about new topics. + {t('presencePenalty.description')}
diff --git a/src/components/ImportExportChat/ImportExportChat.tsx b/src/components/ImportExportChat/ImportExportChat.tsx index 2be55b8..f01d3aa 100644 --- a/src/components/ImportExportChat/ImportExportChat.tsx +++ b/src/components/ImportExportChat/ImportExportChat.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import ExportIcon from '@icon/ExportIcon'; @@ -8,6 +9,7 @@ import PopupModal from '@components/PopupModal'; import { isChats } from '@utils/chat'; const ImportExportChat = () => { + const { t } = useTranslation(); const [isModalOpen, setIsModalOpen] = useState(false); return ( @@ -19,11 +21,11 @@ const ImportExportChat = () => { }} > - Import / Export + {t('import')} / {t('export')} {isModalOpen && ( @@ -38,6 +40,7 @@ const ImportExportChat = () => { }; const ImportChat = () => { + const { t } = useTranslation(); const setChats = useStore.getState().setChats; const inputRef = useRef(null); const [alert, setAlert] = useState<{ @@ -74,7 +77,7 @@ const ImportChat = () => { return ( <> { className='btn btn-small btn-primary mt-3' onClick={handleFileUpload} > - Import + {t('import')} {alert && (
{ }; const ExportChat = () => { + const { t } = useTranslation(); const chats = useStore.getState().chats; return (
- Export (JSON) + {t('export')} (JSON)
); diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 0000000..ea6999d --- /dev/null +++ b/src/components/LanguageSelector/LanguageSelector.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import DownChevronArrow from '@icon/DownChevronArrow'; +import { i18nLanguages } from '@src/i18n'; + +const LanguageSelector = () => { + const { i18n } = useTranslation(); + + const [dropDown, setDropDown] = useState(false); + return ( +
+ + +
+ ); +}; + +export default LanguageSelector; diff --git a/src/components/LanguageSelector/index.ts b/src/components/LanguageSelector/index.ts new file mode 100644 index 0000000..30a89c4 --- /dev/null +++ b/src/components/LanguageSelector/index.ts @@ -0,0 +1 @@ +export { default } from './LanguageSelector'; diff --git a/src/components/LoadingScreen/LoadingScreen.tsx b/src/components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000..abd35e0 --- /dev/null +++ b/src/components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import SpinnerIcon from '@icon/SpinnerIcon'; + +const LoadingScreen = () => { + return ( +
+ +
+ ); +}; + +export default LoadingScreen; diff --git a/src/components/LoadingScreen/index.ts b/src/components/LoadingScreen/index.ts new file mode 100644 index 0000000..efecfe1 --- /dev/null +++ b/src/components/LoadingScreen/index.ts @@ -0,0 +1 @@ +export { default } from './LoadingScreen'; diff --git a/src/components/Menu/MenuOptions/Api.tsx b/src/components/Menu/MenuOptions/Api.tsx index f5fcfe7..0e5772b 100644 --- a/src/components/Menu/MenuOptions/Api.tsx +++ b/src/components/Menu/MenuOptions/Api.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import PersonIcon from '@icon/PersonIcon'; import ApiMenu from '@components/ApiMenu'; const Config = () => { + const { t } = useTranslation(); const apiFree = useStore((state) => state.apiFree); const [isModalOpen, setIsModalOpen] = useState(false); @@ -15,7 +17,7 @@ const Config = () => { onClick={() => setIsModalOpen(true)} > - API: {apiFree ? 'Free' : 'Personal'} + {t('api')}: {apiFree ? t('free') : t('personal')} diff --git a/src/components/Menu/MenuOptions/ClearConversation.tsx b/src/components/Menu/MenuOptions/ClearConversation.tsx index f5fdcc0..6c1cc8d 100644 --- a/src/components/Menu/MenuOptions/ClearConversation.tsx +++ b/src/components/Menu/MenuOptions/ClearConversation.tsx @@ -1,10 +1,13 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import PopupModal from '@components/PopupModal'; import DeleteIcon from '@icon/DeleteIcon'; import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; const ClearConversation = () => { + const { t } = useTranslation(); + const initialiseNewChat = useInitialiseNewChat(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -22,13 +25,13 @@ const ClearConversation = () => { }} > - Clear conversations + {t('clearConversation')} {isModalOpen && ( )} diff --git a/src/components/Menu/MenuOptions/Me.tsx b/src/components/Menu/MenuOptions/Me.tsx index e3796d5..fdc0911 100644 --- a/src/components/Menu/MenuOptions/Me.tsx +++ b/src/components/Menu/MenuOptions/Me.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import HeartIcon from '@icon/HeartIcon'; const Me = () => { + const { t } = useTranslation(); return ( { target='_blank' > - Made by Jing Hua + {t('author')} ); }; diff --git a/src/components/Menu/MenuOptions/MenuOptions.tsx b/src/components/Menu/MenuOptions/MenuOptions.tsx index 607ca64..ee3d1f7 100644 --- a/src/components/Menu/MenuOptions/MenuOptions.tsx +++ b/src/components/Menu/MenuOptions/MenuOptions.tsx @@ -3,12 +3,10 @@ import React from 'react'; import Account from './Account'; import ClearConversation from './ClearConversation'; import Api from './Api'; -import Logout from './Logout'; import Me from './Me'; -import ThemeSwitcher from './ThemeSwitcher'; -import Updates from './Updates'; import AboutMenu from '@components/AboutMenu'; import ImportExportChat from '@components/ImportExportChat'; +import SettingsMenu from '@components/SettingsMenu'; const MenuOptions = () => { return ( @@ -17,11 +15,8 @@ const MenuOptions = () => { - - {/* */} - {/* */} + - {/* */} ); }; diff --git a/src/components/Menu/MenuOptions/ThemeSwitcher.tsx b/src/components/Menu/MenuOptions/ThemeSwitcher.tsx index 50f1dd7..01befc0 100644 --- a/src/components/Menu/MenuOptions/ThemeSwitcher.tsx +++ b/src/components/Menu/MenuOptions/ThemeSwitcher.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import SunIcon from '@icon/SunIcon'; import MoonIcon from '@icon/MoonIcon'; @@ -12,6 +13,7 @@ const getOppositeTheme = (theme: Theme): Theme => { } }; const ThemeSwitcher = () => { + const { t } = useTranslation(); const theme = useStore((state) => state.theme); const setTheme = useStore((state) => state.setTheme); @@ -24,15 +26,13 @@ const ThemeSwitcher = () => { }, [theme]); return theme ? ( - {theme === 'dark' ? : } - {getOppositeTheme(theme).charAt(0).toUpperCase() + - getOppositeTheme(theme).slice(1)}{' '} - mode - + {t(getOppositeTheme(theme) + 'Mode')} + ) : ( <> ); diff --git a/src/components/Menu/NewChat.tsx b/src/components/Menu/NewChat.tsx index dd1d8a3..6f3a900 100644 --- a/src/components/Menu/NewChat.tsx +++ b/src/components/Menu/NewChat.tsx @@ -1,10 +1,12 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import PlusIcon from '@icon/PlusIcon'; import useAddChat from '@hooks/useAddChat'; const NewChat = () => { + const { t } = useTranslation(); const addChat = useAddChat(); return ( @@ -13,7 +15,9 @@ const NewChat = () => { onClick={addChat} > {' '} - New chat + + {t('newChat')} + ); }; diff --git a/src/components/PopupModal/PopupModal.tsx b/src/components/PopupModal/PopupModal.tsx index b28a39e..2bcfc97 100644 --- a/src/components/PopupModal/PopupModal.tsx +++ b/src/components/PopupModal/PopupModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { useTranslation } from 'react-i18next'; import CrossIcon2 from '@icon/CrossIcon2'; @@ -21,6 +22,7 @@ const PopupModal = ({ children?: React.ReactElement; }) => { const modalRoot = document.getElementById('modal-root'); + const { t } = useTranslation(); const _handleClose = () => { handleClose && handleClose(); @@ -62,7 +64,7 @@ const PopupModal = ({ className='btn btn-primary' onClick={handleConfirm} > - Confirm + {t('confirm')} )} {cancelButton && ( @@ -71,7 +73,7 @@ const PopupModal = ({ className='btn btn-neutral' onClick={_handleClose} > - Cancel + {t('cancel')} )}
diff --git a/src/components/SettingsMenu/SettingsMenu.tsx b/src/components/SettingsMenu/SettingsMenu.tsx new file mode 100644 index 0000000..1bd2bf3 --- /dev/null +++ b/src/components/SettingsMenu/SettingsMenu.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useStore from '@store/store'; + +import PopupModal from '@components/PopupModal'; +import SettingIcon from '@icon/SettingIcon'; +import ThemeSwitcher from '@components/Menu/MenuOptions/ThemeSwitcher'; +import LanguageSelector from '@components/LanguageSelector'; + +const SettingsMenu = () => { + const { t } = useTranslation(); + + const theme = useStore.getState().theme; + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + document.documentElement.className = theme; + }, [theme]); + return ( + <> + { + setIsModalOpen(true); + }} + > + {t('setting') as string} + + {isModalOpen && ( + +
+ + +
+
+ )} + + ); +}; + +export default SettingsMenu; diff --git a/src/components/SettingsMenu/index.ts b/src/components/SettingsMenu/index.ts new file mode 100644 index 0000000..bb5de40 --- /dev/null +++ b/src/components/SettingsMenu/index.ts @@ -0,0 +1 @@ +export { default } from './SettingsMenu'; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..465978a --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,16 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +export const i18nLanguages = ['en', 'zh-CN']; + +i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({ + fallbackLng: 'en', + debug: true, + ns: 'main', + defaultNS: 'main', +}); + +export default i18n; diff --git a/src/main.tsx b/src/main.tsx index f4721f7..3ae446f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import './main.css'; +import './i18n'; + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/tsconfig.json b/tsconfig.json index 77430d0..83c759a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "@constants/*": ["./src/constants/*"], "@api/*": ["./src/api/*"], "@components/*": ["./src/components/*"], - "@utils/*": ["./src/utils/*"] + "@utils/*": ["./src/utils/*"], + "@src/*": ["./src/*"] } }, "include": ["src"], diff --git a/vite.config.ts b/vite.config.ts index fe439a6..2e21208 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ '@api/': new URL('./src/api/', import.meta.url).pathname, '@components/': new URL('./src/components/', import.meta.url).pathname, '@utils/': new URL('./src/utils/', import.meta.url).pathname, + '@src/': new URL('./src/', import.meta.url).pathname, }, }, }); diff --git a/yarn.lock b/yarn.lock index c132c1e..0526b56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43,7 +43,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.18.3": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== @@ -672,6 +672,13 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + css-line-break@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" @@ -961,6 +968,13 @@ highlight.js@~11.7.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e" integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ== +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html2canvas@^1.0.0-rc.5, html2canvas@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" @@ -969,6 +983,27 @@ html2canvas@^1.0.0-rc.5, html2canvas@^1.4.1: css-line-break "^2.1.0" text-segmentation "^1.0.3" +i18next-browser-languagedetector@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.0.1.tgz#ead34592edc96c6c3a618a51cb57ad027c5b5d87" + integrity sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g== + dependencies: + "@babel/runtime" "^7.19.4" + +i18next-http-backend@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.1.tgz#72a21d61c2e96eea9ad45ba1b9dd0090e119709a" + integrity sha512-jByfUCDVgQ8+/Wens7queQhYYvMcGTW/lR4IJJNEDDXnmqjLrwi8ubXKpmp76/JIWEZHffNdWqnxFJcTVGeaOw== + dependencies: + cross-fetch "3.1.5" + +i18next@^22.4.11: + version "22.4.11" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.4.11.tgz#8b6c9be95176de90d3f10a78af125d95d3a3258d" + integrity sha512-ShfTzXVMjXdF2iPiT/wbizOrssLh9Ab6VpuVROihLCAu+u25KbZiEYVgsA0W6g0SgjPa/JmGWcUEV/g6cKzEjQ== + dependencies: + "@babel/runtime" "^7.20.6" + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -1596,6 +1631,13 @@ nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-releases@^2.0.8: version "2.0.10" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" @@ -1782,6 +1824,14 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-i18next@^12.2.0: + version "12.2.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-12.2.0.tgz#010e3f6070b8d700442947233352ebe4b252d7a1" + integrity sha512-5XeVgSygaGfyFmDd2WcXvINRw2WEC1XviW1LXY/xLOEMzsCFRwKqfnHN+hUjla8ZipbVJR27GCMSuTr0BhBBBQ== + dependencies: + "@babel/runtime" "^7.20.6" + html-parse-stringify "^3.0.1" + react-is@^16.13.1, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -2089,6 +2139,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" @@ -2249,11 +2304,29 @@ vite@^4.1.0: optionalDependencies: fsevents "~2.3.2" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + web-namespaces@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"