feat: i18n language support, settings menu, loading screen

Fixes #7
This commit is contained in:
Jing Hua 2023-03-12 01:40:26 +08:00
parent 693ac35b03
commit 2bf7f2d710
39 changed files with 578 additions and 104 deletions

View file

@ -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 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) | <img src="https://ayaka14732.github.io/sponsor/alipay.jpg" width=150 /> |
| Wechat (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/wechat.png" width=150 /> |
Thank you for being a part of our community, and we look forward to serving you better in the future. Thank you for being a part of our community, and we look forward to serving you better in the future.

View file

@ -10,9 +10,13 @@
}, },
"dependencies": { "dependencies": {
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18next": "^22.4.11",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.2.0",
"react-markdown": "^8.0.5", "react-markdown": "^8.0.5",
"react-scroll-to-bottom": "^4.2.0", "react-scroll-to-bottom": "^4.2.0",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",

View file

@ -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</0>. 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</0> 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"
}
}

View file

@ -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</0>: 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</0>",
"inputLabel": "API Key"
}
}

View file

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

View file

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

View file

@ -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</0>。我们迫不及待地想见到您!"
},
"privacyStatement": {
"title": "隐私声明",
"paragraph1": "我们非常重视您的隐私,并致力于保护用户的隐私。我们不会以任何形式收集或存储您输入或从 OpenAI 服务器接收的任何文本。我们的源代码可以供您检查,以验证此声明。",
"paragraph2": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。如果您使用自己的 API 密钥,您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不会用于任何其他未经授权的用途。"
},
"support": {
"title": "支持",
"paragraph1": "在 Free ChatGPT我们致力于为您提供实用和惊人的功能。就像任何项目一样您的支持和激励将对我们在保持前进方面起到至关重要的作用",
"paragraph2": "如果您喜欢使用我们的应用程序,我们恳请您给这个<0>项目</0>一个 ⭐️。您的认可对我们意义重大,鼓励我们更加努力,以提供最佳的体验。",
"paragraph3": "如果您想支持我们的团队,请考虑通过以下方法之一赞助我们。每一份贡献,无论多小,都有助于我们维护和改善我们的服务。",
"paragraph4": "感谢您成为我们社区的一员,我们期待着在未来为您提供更好的服务。",
"alipay": "支付宝",
"wechatPay": "微信支付"
}
}

View file

@ -0,0 +1,13 @@
{
"securityMessage": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不是用于任何其他未经授权的用途。",
"apiEndpoint": {
"option": "使用免费的 API 端点",
"inputLabel": "免费的 API 端点",
"description": "使用 <0>Ayaka</0> 提供的免费 API 端点https://chatgpt-api.shn.hk/v1/,或输入您自己的 API 端点"
},
"apiKey": {
"option": "使用自己的 API 密钥",
"howTo": "在<0>这里</0>获取您的个人 API 密钥",
"inputLabel": "API 密钥"
}
}

View file

@ -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": "设置"
}

View file

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

View file

@ -1,8 +1,9 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, Suspense } from 'react';
import useStore from '@store/store'; import useStore from '@store/store';
import Chat from './components/Chat'; import Chat from '@components/Chat';
import Menu from './components/Menu'; import Menu from '@components/Menu';
import LoadingScreen from '@components/LoadingScreen';
import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; import useInitialiseNewChat from '@hooks/useInitialiseNewChat';
import { ChatInterface } from '@type/chat'; import { ChatInterface } from '@type/chat';
@ -65,10 +66,12 @@ function App() {
}, []); }, []);
return ( return (
<Suspense fallback={<LoadingScreen />}>
<div className='overflow-hidden w-full h-full relative'> <div className='overflow-hidden w-full h-full relative'>
<Menu /> <Menu />
<Chat /> <Chat />
</div> </div>
</Suspense>
); );
} }

View file

@ -1,8 +1,13 @@
import React from 'react'; import React from 'react';
const SettingIcon = () => { const SettingIcon = (props: React.SVGProps<SVGSVGElement>) => {
return ( return (
<svg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 512 512'> <svg
xmlns='http://www.w3.org/2000/svg'
fill='white'
viewBox='0 0 512 512'
{...props}
>
<path d='M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z' /> <path d='M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z' />
</svg> </svg>
); );

View file

@ -0,0 +1,24 @@
import React from 'react';
const SpinnerIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
aria-hidden='true'
viewBox='0 0 100 101'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'
fill='currentColor'
/>
<path
d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'
fill='currentFill'
/>
</svg>
);
};
export default SpinnerIcon;

View file

@ -1,9 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import AboutIcon from '@icon/AboutIcon'; import AboutIcon from '@icon/AboutIcon';
import Updates from '@components/Menu/MenuOptions/Updates'; import Updates from '@components/Menu/MenuOptions/Updates';
const AboutMenu = () => { const AboutMenu = () => {
const { t } = useTranslation(['main', 'about']);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
return ( return (
@ -17,7 +19,7 @@ const AboutMenu = () => {
<div> <div>
<AboutIcon /> <AboutIcon />
</div> </div>
About {t('about')}
</a> </a>
{isModalOpen && ( {isModalOpen && (
<PopupModal <PopupModal
@ -27,26 +29,81 @@ const AboutMenu = () => {
> >
<div className='p-6 border-b border-gray-200 dark:border-gray-600'> <div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm flex flex-col gap-2'> <div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm flex flex-col gap-2'>
<p>Free ChatGPT is an amazing open-source web app that allows you to play with OpenAI's ChatGPT API for free!</p> <p>{t('description', { ns: 'about' })}</p>
<Updates isButton /> <Updates isButton />
<h2 className='text-lg font-bold'>Discord Server</h2> <h2 className='text-lg font-bold'>
<p>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.</p> {t('discordServer.title', { ns: 'about' })}
</h2>
<p>{t('discordServer.paragraph1', { ns: 'about' })}</p>
<p>To join our server, simply click on the following link: <a className='link' href='https://discord.gg/g3Qnwy4V6A' target='_blank'>https://discord.gg/g3Qnwy4V6A</a>. We can't wait to see you there!</p> <p>
<Trans
i18nKey='discordServer.paragraph2'
ns='about'
components={[
<a
className='link'
href='https://discord.gg/g3Qnwy4V6A'
target='_blank'
/>,
]}
/>
</p>
<h2 className='text-lg font-bold'>Privacy Statement</h2> <h2 className='text-lg font-bold'>
<p>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.</p> {t('privacyStatement.title', { ns: 'about' })}
</h2>
<p>{t('privacyStatement.paragraph1', { ns: 'about' })}</p>
<p>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.</p> <p>{t('privacyStatement.paragraph2', { ns: 'about' })}</p>
<h2 className='text-lg font-bold'>Support</h2> <h2 className='text-lg font-bold'>
<p>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!</p> {t('support.title', { ns: 'about' })}
<p>If you have enjoyed using our app, we kindly ask you to give this <a href="https://github.com/ztjhz/ChatGPTFreeApp/" target="_blank" className="link">project</a> a . Your endorsement means a lot to us and encourages us to work harder towards delivering the best possible experience.</p> </h2>
<p>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.</p> <p>{t('support.paragraph1', { ns: 'about' })}</p>
<a href="https://ko-fi.com/freechatgpt" target="_blank"> <p>
<img src="/kofi.svg" alt="Support us through the Ko-fi platform." /> <Trans
i18nKey='support.paragraph2'
ns='about'
components={[
<a
href='https://github.com/ztjhz/ChatGPTFreeApp/'
target='_blank'
className='link'
/>,
]}
/>
</p>
<p>{t('support.paragraph3', { ns: 'about' })}</p>
<div className='flex flex-col items-center gap-4 my-4'>
<a href='https://ko-fi.com/freechatgpt' target='_blank'>
<img
src='/kofi.svg'
alt='Support us through the Ko-fi platform.'
/>
</a> </a>
<p>Thank you for being a part of our community, and we look forward to serving you better in the future.</p> <div className='flex gap-x-10 gap-y-4 flex-wrap justify-center'>
<div className='flex flex-col items-center justify-center gap-1'>
<div>{t('support.alipay', { ns: 'about' })} (Ayaka)</div>
<img
className='rounded-md w-32 h-32'
src='https://ayaka14732.github.io/sponsor/alipay.jpg'
alt='Support us through Alipay'
/>
</div>
<div className='flex flex-col items-center justify-center gap-1'>
<div>{t('support.wechatPay', { ns: 'about' })} (Ayaka)</div>
<img
className='rounded-md w-32 h-32'
src='https://ayaka14732.github.io/sponsor/wechat.png'
alt='Support us through WeChat Pay'
/>
</div>
</div>
</div>
<p className='text-center'>
{t('support.paragraph4', { ns: 'about' })}
</p>
</div> </div>
</div> </div>
</PopupModal> </PopupModal>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
@ -10,6 +11,8 @@ const ApiMenu = ({
isModalOpen: boolean; isModalOpen: boolean;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => { }) => {
const { t } = useTranslation(['main', 'api']);
const apiKey = useStore((state) => state.apiKey); const apiKey = useStore((state) => state.apiKey);
const setApiKey = useStore((state) => state.setApiKey); const setApiKey = useStore((state) => state.setApiKey);
const apiFree = useStore((state) => state.apiFree); const apiFree = useStore((state) => state.apiFree);
@ -50,7 +53,7 @@ const ApiMenu = ({
return isModalOpen ? ( return isModalOpen ? (
<PopupModal <PopupModal
title='API' title={t('api') as string}
setIsModalOpen={setIsModalOpen} setIsModalOpen={setIsModalOpen}
handleConfirm={handleSave} handleConfirm={handleSave}
handleClose={handleClose} handleClose={handleClose}
@ -64,26 +67,28 @@ const ApiMenu = ({
onChange={() => _setApiFree(true)} onChange={() => _setApiFree(true)}
/> />
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'> <label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
Use free API endpoint {t('apiEndpoint.option', { ns: 'api' })}
</label> </label>
</div> </div>
{_apiFree && ( {_apiFree && (
<div className='mt-2 mb-6'> <div className='mt-2 mb-6'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-300 mb-2'> <div className='text-sm font-medium text-gray-900 dark:text-gray-300 mb-2'>
Use free API endpoint from{' '} <Trans
i18nKey='apiEndpoint.description'
ns='api'
components={[
<a <a
href='https://github.com/ayaka14732/ChatGPTAPIFree' href='https://github.com/ayaka14732/ChatGPTAPIFree'
className='underline dark:hover:text-white hover:text-black' className='link'
target='_blank' target='_blank'
> />,
Ayaka ]}
</a> />
: https://chatgpt-api.shn.hk/v1/ or enter your own API endpoint
</div> </div>
<div className='flex gap-2 items-center justify-center'> <div className='flex gap-2 items-center justify-center'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'> <div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'>
Free API Endpoint {t('apiEndpoint.inputLabel', { ns: 'api' })}
</div> </div>
<input <input
type='text' type='text'
@ -106,14 +111,14 @@ const ApiMenu = ({
onChange={() => _setApiFree(false)} onChange={() => _setApiFree(false)}
/> />
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'> <label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
Use your own API key {t('apiKey.option', { ns: 'api' })}
</label> </label>
</div> </div>
{_apiFree === false && ( {_apiFree === false && (
<div className='flex gap-2 items-center justify-center mt-2'> <div className='flex gap-2 items-center justify-center mt-2'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'> <div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'>
API Key {t('apiEndpoint.inputLabel', { ns: 'api' })}
</div> </div>
<input <input
type='text' type='text'
@ -127,21 +132,20 @@ const ApiMenu = ({
)} )}
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm mt-4 text-center'> <div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm mt-4 text-center'>
Get your personal API key{' '} <Trans
i18nKey='apiKey.howTo'
ns='api'
components={[
<a <a
className='link'
href='https://platform.openai.com/account/api-keys' href='https://platform.openai.com/account/api-keys'
className='link'
target='_blank' target='_blank'
> />,
here ]}
</a> />
</div> </div>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm mt-4'> <div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm mt-4'>
We prioritise the security of your API key and handle it with utmost {t('securityMessage', { ns: 'api' })}
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.
</div> </div>
</div> </div>
</PopupModal> </PopupModal>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import useStore from '@store/store'; import useStore from '@store/store';
import ConfigMenu from '@components/ConfigMenu'; import ConfigMenu from '@components/ConfigMenu';
@ -6,6 +7,7 @@ import { ChatInterface, ConfigInterface } from '@type/chat';
import { defaultChatConfig } from '@constants/chat'; import { defaultChatConfig } from '@constants/chat';
const ChatTitle = React.memo(() => { const ChatTitle = React.memo(() => {
const { t } = useTranslation('model');
const config = useStore( const config = useStore(
(state) => (state) =>
state.chats && state.chats &&
@ -47,13 +49,13 @@ const ChatTitle = React.memo(() => {
}} }}
> >
<div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'> <div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'>
Model: Default {t('model')}: {t('default')}
</div> </div>
<div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'> <div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'>
Temperature: {config.temperature} {t('temperature.label')}: {config.temperature}
</div> </div>
<div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'> <div className='text-center p-1 rounded-md bg-gray-900/10 hover:bg-gray-900/50'>
PresencePenalty: {config.presence_penalty} {t('presencePenalty.label')}: {config.presence_penalty}
</div> </div>
</div> </div>
{isModalOpen && ( {isModalOpen && (

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import { import {
@ -14,6 +15,7 @@ import MarkdownIcon from '@icon/MarkdownIcon';
const DownloadChat = React.memo( const DownloadChat = React.memo(
({ saveRef }: { saveRef: React.RefObject<HTMLDivElement> }) => { ({ saveRef }: { saveRef: React.RefObject<HTMLDivElement> }) => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
return ( return (
<> <>
@ -23,7 +25,7 @@ const DownloadChat = React.memo(
setIsModalOpen(true); setIsModalOpen(true);
}} }}
> >
Download Chat {t('downloadChat')}
</button> </button>
{isModalOpen && ( {isModalOpen && (
<PopupModal <PopupModal

View file

@ -4,6 +4,7 @@ import React, {
useEffect, useEffect,
useState, useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react'; import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
@ -303,6 +304,8 @@ const EditView = ({
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const textareaRef = React.createRef<HTMLTextAreaElement>(); const textareaRef = React.createRef<HTMLTextAreaElement>();
const { t } = useTranslation();
const resetTextAreaHeight = () => { const resetTextAreaHeight = () => {
if (textareaRef.current) textareaRef.current.style.height = 'auto'; if (textareaRef.current) textareaRef.current.style.height = 'auto';
}; };
@ -403,8 +406,8 @@ const EditView = ({
{isModalOpen && ( {isModalOpen && (
<PopupModal <PopupModal
setIsModalOpen={setIsModalOpen} setIsModalOpen={setIsModalOpen}
title='Warning' title={t('warning') as string}
message='Please be advised that by submitting this message, all subsequent messages will be deleted!' message={t('clearMessageWarning') as string}
handleConfirm={handleSaveAndSubmit} handleConfirm={handleSaveAndSubmit}
/> />
)} )}
@ -426,6 +429,8 @@ const EditViewButtons = React.memo(
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>; setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
}) => { }) => {
const { t } = useTranslation();
return ( return (
<div className='text-center mt-2 flex justify-center'> <div className='text-center mt-2 flex justify-center'>
{sticky && ( {sticky && (
@ -434,7 +439,7 @@ const EditViewButtons = React.memo(
onClick={handleSaveAndSubmit} onClick={handleSaveAndSubmit}
> >
<div className='flex items-center justify-center gap-2'> <div className='flex items-center justify-center gap-2'>
Save & Submit {t('saveAndSubmit')}
</div> </div>
</button> </button>
)} )}
@ -445,7 +450,9 @@ const EditViewButtons = React.memo(
}`} }`}
onClick={handleSave} onClick={handleSave}
> >
<div className='flex items-center justify-center gap-2'>Save</div> <div className='flex items-center justify-center gap-2'>
{t('save')}
</div>
</button> </button>
{sticky || ( {sticky || (
@ -456,7 +463,7 @@ const EditViewButtons = React.memo(
}} }}
> >
<div className='flex items-center justify-center gap-2'> <div className='flex items-center justify-center gap-2'>
Save & Submit {t('saveAndSubmit')}
</div> </div>
</button> </button>
)} )}
@ -466,7 +473,9 @@ const EditViewButtons = React.memo(
className='btn relative btn-neutral' className='btn relative btn-neutral'
onClick={() => setIsEdit(false)} onClick={() => setIsEdit(false)}
> >
<div className='flex items-center justify-center gap-2'>Cancel</div> <div className='flex items-center justify-center gap-2'>
{t('cancel')}
</div>
</button> </button>
)} )}
</div> </div>

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import DownChevronArrow from '@icon/DownChevronArrow'; import DownChevronArrow from '@icon/DownChevronArrow';
@ -14,6 +15,7 @@ const RoleSelector = React.memo(
messageIndex: number; messageIndex: number;
sticky?: boolean; sticky?: boolean;
}) => { }) => {
const { t } = useTranslation();
const setInputRole = useStore((state) => state.setInputRole); const setInputRole = useStore((state) => state.setInputRole);
const setChats = useStore((state) => state.setChats); const setChats = useStore((state) => state.setChats);
const currentChatIndex = useStore((state) => state.currentChatIndex); const currentChatIndex = useStore((state) => state.currentChatIndex);
@ -27,7 +29,7 @@ const RoleSelector = React.memo(
type='button' type='button'
onClick={() => setDropDown((prev) => !prev)} onClick={() => setDropDown((prev) => !prev)}
> >
{role.charAt(0).toUpperCase() + role.slice(1)} {t(role)}
<DownChevronArrow /> <DownChevronArrow />
</button> </button>
<div <div
@ -58,7 +60,7 @@ const RoleSelector = React.memo(
}} }}
key={r} key={r}
> >
{r.charAt(0).toUpperCase() + r.slice(1)} {t(r)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import { ConfigInterface } from '@type/chat'; import { ConfigInterface } from '@type/chat';
@ -15,6 +16,7 @@ const ConfigMenu = ({
const [_presencePenalty, _setPresencePenalty] = useState<number>( const [_presencePenalty, _setPresencePenalty] = useState<number>(
config.presence_penalty config.presence_penalty
); );
const { t } = useTranslation('model');
const handleConfirm = () => { const handleConfirm = () => {
setConfig({ setConfig({
@ -26,14 +28,14 @@ const ConfigMenu = ({
return ( return (
<PopupModal <PopupModal
title='Configuration' title={t('configuration') as string}
setIsModalOpen={setIsModalOpen} setIsModalOpen={setIsModalOpen}
handleConfirm={handleConfirm} handleConfirm={handleConfirm}
> >
<div className='p-6 border-b border-gray-200 dark:border-gray-600'> <div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<div> <div>
<label className='block text-sm font-medium text-gray-900 dark:text-white'> <label className='block text-sm font-medium text-gray-900 dark:text-white'>
Temperature: {_temperature} {t('temperature.label')}: {_temperature}
</label> </label>
<input <input
id='default-range' id='default-range'
@ -48,14 +50,12 @@ const ConfigMenu = ({
className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer' className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer'
/> />
<div className='min-w-fit text-gray-500 dark:text-gray-300 text-sm mt-2'> <div className='min-w-fit text-gray-500 dark:text-gray-300 text-sm mt-2'>
What sampling temperature to use, between 0 and 2. Higher values {t('temperature.description')}
like 0.8 will make the output more random, while lower values like
0.2 will make it more focused and deterministic.
</div> </div>
</div> </div>
<div className='mt-5 pt-5 border-t border-gray-500'> <div className='mt-5 pt-5 border-t border-gray-500'>
<label className='block text-sm font-medium text-gray-900 dark:text-white'> <label className='block text-sm font-medium text-gray-900 dark:text-white'>
Presence Penalty: {_presencePenalty} {t('presencePenalty.label')}: {_presencePenalty}
</label> </label>
<input <input
id='default-range' id='default-range'
@ -70,9 +70,7 @@ const ConfigMenu = ({
className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer' className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer'
/> />
<div className='min-w-fit text-gray-500 dark:text-gray-300 text-sm mt-2'> <div className='min-w-fit text-gray-500 dark:text-gray-300 text-sm mt-2'>
Number between -2.0 and 2.0. Positive values penalize new tokens {t('presencePenalty.description')}
based on whether they appear in the text so far, increasing the
model's likelihood to talk about new topics.
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import ExportIcon from '@icon/ExportIcon'; import ExportIcon from '@icon/ExportIcon';
@ -8,6 +9,7 @@ import PopupModal from '@components/PopupModal';
import { isChats } from '@utils/chat'; import { isChats } from '@utils/chat';
const ImportExportChat = () => { const ImportExportChat = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
return ( return (
@ -19,11 +21,11 @@ const ImportExportChat = () => {
}} }}
> >
<ExportIcon className='w-4 h-4' /> <ExportIcon className='w-4 h-4' />
Import / Export {t('import')} / {t('export')}
</a> </a>
{isModalOpen && ( {isModalOpen && (
<PopupModal <PopupModal
title='Import / Export' title={`${t('import')} / ${t('export')}`}
setIsModalOpen={setIsModalOpen} setIsModalOpen={setIsModalOpen}
cancelButton={false} cancelButton={false}
> >
@ -38,6 +40,7 @@ const ImportExportChat = () => {
}; };
const ImportChat = () => { const ImportChat = () => {
const { t } = useTranslation();
const setChats = useStore.getState().setChats; const setChats = useStore.getState().setChats;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [alert, setAlert] = useState<{ const [alert, setAlert] = useState<{
@ -74,7 +77,7 @@ const ImportChat = () => {
return ( return (
<> <>
<label className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'> <label className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
Import (JSON) {t('import')} (JSON)
</label> </label>
<input <input
className='w-full text-sm file:p-2 text-gray-800 file:text-gray-700 dark:text-gray-300 dark:file:text-gray-200 rounded-md cursor-pointer focus:outline-none bg-gray-50 file:bg-gray-100 dark:bg-gray-800 dark:file:bg-gray-700 file:border-0 border border-gray-300 dark:border-gray-600 placeholder-gray-900 dark:placeholder-gray-300 file:cursor-pointer' className='w-full text-sm file:p-2 text-gray-800 file:text-gray-700 dark:text-gray-300 dark:file:text-gray-200 rounded-md cursor-pointer focus:outline-none bg-gray-50 file:bg-gray-100 dark:bg-gray-800 dark:file:bg-gray-700 file:border-0 border border-gray-300 dark:border-gray-600 placeholder-gray-900 dark:placeholder-gray-300 file:cursor-pointer'
@ -85,7 +88,7 @@ const ImportChat = () => {
className='btn btn-small btn-primary mt-3' className='btn btn-small btn-primary mt-3'
onClick={handleFileUpload} onClick={handleFileUpload}
> >
Import {t('import')}
</button> </button>
{alert && ( {alert && (
<div <div
@ -103,11 +106,12 @@ const ImportChat = () => {
}; };
const ExportChat = () => { const ExportChat = () => {
const { t } = useTranslation();
const chats = useStore.getState().chats; const chats = useStore.getState().chats;
return ( return (
<div className='mt-6'> <div className='mt-6'>
<div className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'> <div className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
Export (JSON) {t('export')} (JSON)
</div> </div>
<button <button
className='btn btn-small btn-primary' className='btn btn-small btn-primary'
@ -115,7 +119,7 @@ const ExportChat = () => {
if (chats) downloadFile(chats, getToday()); if (chats) downloadFile(chats, getToday());
}} }}
> >
Export {t('export')}
</button> </button>
</div> </div>
); );

View file

@ -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<boolean>(false);
return (
<div className='prose dark:prose-invert relative'>
<button
className='btn btn-neutral btn-small flex w-32 flex justify-between'
type='button'
onClick={() => setDropDown((prev) => !prev)}
>
{i18n.language}
<DownChevronArrow />
</button>
<div
id='dropdown'
className={`${
dropDown ? '' : 'hidden'
} absolute top-100 bottom-100 z-10 bg-white rounded-lg shadow-xl border-b border-black/10 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group dark:bg-gray-800 opacity-90 w-32`}
>
<ul
className='text-sm text-gray-700 dark:text-gray-200 p-0 m-0'
aria-labelledby='dropdownDefaultButton'
>
{i18nLanguages.map((lang) => (
<li
className='px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white cursor-pointer'
onClick={() => {
i18n.changeLanguage(lang);
setDropDown(false);
}}
key={lang}
>
{lang}
</li>
))}
</ul>
</div>
</div>
);
};
export default LanguageSelector;

View file

@ -0,0 +1 @@
export { default } from './LanguageSelector';

View file

@ -0,0 +1,12 @@
import React from 'react';
import SpinnerIcon from '@icon/SpinnerIcon';
const LoadingScreen = () => {
return (
<div className='w-full h-full flex justify-center items-center bg-gray-800 text-gray-100'>
<SpinnerIcon className='inline w-12 h-12 text-gray-400 fill-green-500/60 animate-spin' />
</div>
);
};
export default LoadingScreen;

View file

@ -0,0 +1 @@
export { default } from './LoadingScreen';

View file

@ -1,10 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import PersonIcon from '@icon/PersonIcon'; import PersonIcon from '@icon/PersonIcon';
import ApiMenu from '@components/ApiMenu'; import ApiMenu from '@components/ApiMenu';
const Config = () => { const Config = () => {
const { t } = useTranslation();
const apiFree = useStore((state) => state.apiFree); const apiFree = useStore((state) => state.apiFree);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
@ -15,7 +17,7 @@ const Config = () => {
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
> >
<PersonIcon /> <PersonIcon />
API: {apiFree ? 'Free' : 'Personal'} {t('api')}: {apiFree ? t('free') : t('personal')}
</a> </a>
<ApiMenu isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} /> <ApiMenu isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} />
</> </>

View file

@ -1,10 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import DeleteIcon from '@icon/DeleteIcon'; import DeleteIcon from '@icon/DeleteIcon';
import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; import useInitialiseNewChat from '@hooks/useInitialiseNewChat';
const ClearConversation = () => { const ClearConversation = () => {
const { t } = useTranslation();
const initialiseNewChat = useInitialiseNewChat(); const initialiseNewChat = useInitialiseNewChat();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
@ -22,13 +25,13 @@ const ClearConversation = () => {
}} }}
> >
<DeleteIcon /> <DeleteIcon />
Clear conversations {t('clearConversation')}
</a> </a>
{isModalOpen && ( {isModalOpen && (
<PopupModal <PopupModal
setIsModalOpen={setIsModalOpen} setIsModalOpen={setIsModalOpen}
title='Warning' title={t('warning') as string}
message='Please be advised that by confirming this action, all messages will be deleted!' message={t('clearConversationWarning') as string}
handleConfirm={handleConfirm} handleConfirm={handleConfirm}
/> />
)} )}

View file

@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import HeartIcon from '@icon/HeartIcon'; import HeartIcon from '@icon/HeartIcon';
const Me = () => { const Me = () => {
const { t } = useTranslation();
return ( return (
<a <a
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm' className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
@ -10,7 +12,7 @@ const Me = () => {
target='_blank' target='_blank'
> >
<HeartIcon /> <HeartIcon />
Made by Jing Hua {t('author')}
</a> </a>
); );
}; };

View file

@ -3,12 +3,10 @@ import React from 'react';
import Account from './Account'; import Account from './Account';
import ClearConversation from './ClearConversation'; import ClearConversation from './ClearConversation';
import Api from './Api'; import Api from './Api';
import Logout from './Logout';
import Me from './Me'; import Me from './Me';
import ThemeSwitcher from './ThemeSwitcher';
import Updates from './Updates';
import AboutMenu from '@components/AboutMenu'; import AboutMenu from '@components/AboutMenu';
import ImportExportChat from '@components/ImportExportChat'; import ImportExportChat from '@components/ImportExportChat';
import SettingsMenu from '@components/SettingsMenu';
const MenuOptions = () => { const MenuOptions = () => {
return ( return (
@ -17,11 +15,8 @@ const MenuOptions = () => {
<ClearConversation /> <ClearConversation />
<ImportExportChat /> <ImportExportChat />
<Api /> <Api />
<ThemeSwitcher /> <SettingsMenu />
{/* <Account /> */}
{/* <Updates /> */}
<Me /> <Me />
{/* <Logout /> */}
</> </>
); );
}; };

View file

@ -1,4 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import SunIcon from '@icon/SunIcon'; import SunIcon from '@icon/SunIcon';
import MoonIcon from '@icon/MoonIcon'; import MoonIcon from '@icon/MoonIcon';
@ -12,6 +13,7 @@ const getOppositeTheme = (theme: Theme): Theme => {
} }
}; };
const ThemeSwitcher = () => { const ThemeSwitcher = () => {
const { t } = useTranslation();
const theme = useStore((state) => state.theme); const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme); const setTheme = useStore((state) => state.setTheme);
@ -24,15 +26,13 @@ const ThemeSwitcher = () => {
}, [theme]); }, [theme]);
return theme ? ( return theme ? (
<a <button
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm' className='items-center gap-3 btn btn-neutral'
onClick={switchTheme} onClick={switchTheme}
> >
{theme === 'dark' ? <SunIcon /> : <MoonIcon />} {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
{getOppositeTheme(theme).charAt(0).toUpperCase() + {t(getOppositeTheme(theme) + 'Mode')}
getOppositeTheme(theme).slice(1)}{' '} </button>
mode
</a>
) : ( ) : (
<></> <></>
); );

View file

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import PlusIcon from '@icon/PlusIcon'; import PlusIcon from '@icon/PlusIcon';
import useAddChat from '@hooks/useAddChat'; import useAddChat from '@hooks/useAddChat';
const NewChat = () => { const NewChat = () => {
const { t } = useTranslation();
const addChat = useAddChat(); const addChat = useAddChat();
return ( return (
@ -13,7 +15,9 @@ const NewChat = () => {
onClick={addChat} onClick={addChat}
> >
<PlusIcon />{' '} <PlusIcon />{' '}
<span className='hidden md:inline-flex text-white text-sm'>New chat</span> <span className='hidden md:inline-flex text-white text-sm'>
{t('newChat')}
</span>
</a> </a>
); );
}; };

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { useTranslation } from 'react-i18next';
import CrossIcon2 from '@icon/CrossIcon2'; import CrossIcon2 from '@icon/CrossIcon2';
@ -21,6 +22,7 @@ const PopupModal = ({
children?: React.ReactElement; children?: React.ReactElement;
}) => { }) => {
const modalRoot = document.getElementById('modal-root'); const modalRoot = document.getElementById('modal-root');
const { t } = useTranslation();
const _handleClose = () => { const _handleClose = () => {
handleClose && handleClose(); handleClose && handleClose();
@ -62,7 +64,7 @@ const PopupModal = ({
className='btn btn-primary' className='btn btn-primary'
onClick={handleConfirm} onClick={handleConfirm}
> >
Confirm {t('confirm')}
</button> </button>
)} )}
{cancelButton && ( {cancelButton && (
@ -71,7 +73,7 @@ const PopupModal = ({
className='btn btn-neutral' className='btn btn-neutral'
onClick={_handleClose} onClick={_handleClose}
> >
Cancel {t('cancel')}
</button> </button>
)} )}
</div> </div>

View file

@ -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<boolean>(false);
useEffect(() => {
document.documentElement.className = theme;
}, [theme]);
return (
<>
<a
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
onClick={() => {
setIsModalOpen(true);
}}
>
<SettingIcon className='w-4 h-4' /> {t('setting') as string}
</a>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title={t('setting') as string}
cancelButton={false}
>
<div className='p-6 border-b border-gray-200 dark:border-gray-600 flex flex-col items-center gap-4'>
<LanguageSelector />
<ThemeSwitcher />
</div>
</PopupModal>
)}
</>
);
};
export default SettingsMenu;

View file

@ -0,0 +1 @@
export { default } from './SettingsMenu';

16
src/i18n.ts Normal file
View file

@ -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;

View file

@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './main.css'; import './main.css';
import './i18n';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View file

@ -24,7 +24,8 @@
"@constants/*": ["./src/constants/*"], "@constants/*": ["./src/constants/*"],
"@api/*": ["./src/api/*"], "@api/*": ["./src/api/*"],
"@components/*": ["./src/components/*"], "@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"] "@utils/*": ["./src/utils/*"],
"@src/*": ["./src/*"]
} }
}, },
"include": ["src"], "include": ["src"],

View file

@ -14,6 +14,7 @@ export default defineConfig({
'@api/': new URL('./src/api/', import.meta.url).pathname, '@api/': new URL('./src/api/', import.meta.url).pathname,
'@components/': new URL('./src/components/', import.meta.url).pathname, '@components/': new URL('./src/components/', import.meta.url).pathname,
'@utils/': new URL('./src/utils/', import.meta.url).pathname, '@utils/': new URL('./src/utils/', import.meta.url).pathname,
'@src/': new URL('./src/', import.meta.url).pathname,
}, },
}, },
}); });

View file

@ -43,7 +43,7 @@
core-js-pure "^3.25.1" core-js-pure "^3.25.1"
regenerator-runtime "^0.13.11" 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" version "7.21.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
@ -672,6 +672,13 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0" path-type "^4.0.0"
yaml "^1.10.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: css-line-break@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" 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" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e"
integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ== 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: html2canvas@^1.0.0-rc.5, html2canvas@^1.4.1:
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" 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" css-line-break "^2.1.0"
text-segmentation "^1.0.3" 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: import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" 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" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== 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: node-releases@^2.0.8:
version "2.0.10" version "2.0.10"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" 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" loose-envify "^1.1.0"
scheduler "^0.23.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: react-is@^16.13.1, react-is@^16.8.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" 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: dependencies:
is-number "^7.0.0" 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: trim-lines@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
@ -2249,11 +2304,29 @@ vite@^4.1.0:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" 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: web-namespaces@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== 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: xtend@^4.0.2:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"