mirror of
				https://github.com/ztjhz/BetterChatGPT.git
				synced 2023-07-20 23:11:29 +03:00 
			
		
		
		
	Sync to Google Drive (#233)
* google drive api * fix: google-api * GoogleCloudStorage * list files api * Google Cloud Storage * move button to side menu * sync status * rename file * show popup for those with cloud sync * update button style * auto close modal after logged in * auto popup every 59min * set as unauthenticated if update fails * i18n * add spin animation * feat: Toast * clear toast * electron: desktop google drive integration This update includes integration with Google Drive for desktop access, but requires a new URL, which may cause existing chat data to be lost. To minimize disruption, users can export their current chat data and import it into the newer version. * update note * error handling * support multiple drive files * feat: delete drive file * i18n * change style
This commit is contained in:
		| @@ -2,4 +2,5 @@ | |||||||
| VITE_CUSTOM_API_ENDPOINT= | VITE_CUSTOM_API_ENDPOINT= | ||||||
| VITE_DEFAULT_API_ENDPOINT= | VITE_DEFAULT_API_ENDPOINT= | ||||||
| VITE_OPENAI_API_KEY= | VITE_OPENAI_API_KEY= | ||||||
| VITE_DEFAULT_SYSTEM_MESSAGE=     # Remove this line if you want to use the default system message of Better ChatGPT | VITE_DEFAULT_SYSTEM_MESSAGE=     # Remove this line if you want to use the default system message of Better ChatGPT | ||||||
|  | VITE_GOOGLE_CLIENT_ID=           # for syncing data with google drive | ||||||
| @@ -6,6 +6,8 @@ const { autoUpdater } = require('electron-updater'); | |||||||
|  |  | ||||||
| if (require('electron-squirrel-startup')) app.quit(); | if (require('electron-squirrel-startup')) app.quit(); | ||||||
|  |  | ||||||
|  | const PORT = isDev ? '5173' : '51735'; | ||||||
|  |  | ||||||
| function createWindow() { | function createWindow() { | ||||||
|   let iconPath = ''; |   let iconPath = ''; | ||||||
|   if (isDev) { |   if (isDev) { | ||||||
| @@ -30,11 +32,9 @@ function createWindow() { | |||||||
|   win.maximize(); |   win.maximize(); | ||||||
|   win.show(); |   win.show(); | ||||||
|  |  | ||||||
|   win.loadURL( |   isDev || createServer(); | ||||||
|     isDev |  | ||||||
|       ? 'http://localhost:5173' |   win.loadURL(`http://localhost:${PORT}`); | ||||||
|       : `file://${path.join(__dirname, '../dist/index.html')}` |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   if (isDev) { |   if (isDev) { | ||||||
|     win.webContents.openDevTools({ mode: 'detach' }); |     win.webContents.openDevTools({ mode: 'detach' }); | ||||||
| @@ -81,3 +81,67 @@ app.on('activate', () => { | |||||||
|     createWindow(); |     createWindow(); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const createServer = () => { | ||||||
|  |   // Dependencies | ||||||
|  |   const http = require('http'); | ||||||
|  |   const fs = require('fs'); | ||||||
|  |   const path = require('path'); | ||||||
|  |  | ||||||
|  |   // MIME types for different file extensions | ||||||
|  |   const mimeTypes = { | ||||||
|  |     '.html': 'text/html', | ||||||
|  |     '.css': 'text/css', | ||||||
|  |     '.js': 'text/javascript', | ||||||
|  |     '.wasm': 'application/wasm', | ||||||
|  |     '.jpg': 'image/jpeg', | ||||||
|  |     '.jpeg': 'image/jpeg', | ||||||
|  |     '.png': 'image/png', | ||||||
|  |     '.gif': 'image/gif', | ||||||
|  |     '.svg': 'image/svg+xml', | ||||||
|  |     '.json': 'application/json', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Create a http server | ||||||
|  |   const server = http.createServer((request, response) => { | ||||||
|  |     // Get the file path from the URL | ||||||
|  |     let filePath = | ||||||
|  |       request.url === '/' ? '../dist/index.html' : `../dist/${request.url}`; | ||||||
|  |  | ||||||
|  |     // Get the file extension from the filePath | ||||||
|  |     let extname = path.extname(filePath); | ||||||
|  |  | ||||||
|  |     // Set the default MIME type to text/plain | ||||||
|  |     let contentType = 'text/plain'; | ||||||
|  |  | ||||||
|  |     // Check if the file extension is in the MIME types object | ||||||
|  |     if (extname in mimeTypes) { | ||||||
|  |       contentType = mimeTypes[extname]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Read the file from the disk | ||||||
|  |     fs.readFile(filePath, (error, content) => { | ||||||
|  |       if (error) { | ||||||
|  |         // If file read error occurs | ||||||
|  |         if (error.code === 'ENOENT') { | ||||||
|  |           // File not found error | ||||||
|  |           response.writeHead(404); | ||||||
|  |           response.end('File Not Found'); | ||||||
|  |         } else { | ||||||
|  |           // Server error | ||||||
|  |           response.writeHead(500); | ||||||
|  |           response.end(`Server Error: ${error.code}`); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         // File read successful | ||||||
|  |         response.writeHead(200, { 'Content-Type': contentType }); | ||||||
|  |         response.end(content, 'utf-8'); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Listen for request on port ${PORT} | ||||||
|  |   server.listen(PORT, () => { | ||||||
|  |     console.log(`Server listening on http://localhost:${PORT}/`); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -41,6 +41,7 @@ | |||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@dqbd/tiktoken": "^1.0.2", |     "@dqbd/tiktoken": "^1.0.2", | ||||||
|  |     "@react-oauth/google": "^0.9.0", | ||||||
|     "electron-is-dev": "^2.0.0", |     "electron-is-dev": "^2.0.0", | ||||||
|     "electron-squirrel-startup": "^1.0.0", |     "electron-squirrel-startup": "^1.0.0", | ||||||
|     "electron-updater": "^5.3.0", |     "electron-updater": "^5.3.0", | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								public/locales/da/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/da/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/en-US/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/en-US/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/en/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/en/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/es/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/es/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/fr/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/fr/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/it/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/it/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/ja/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/ja/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/ms/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/ms/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/nb/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/nb/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/sv/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/sv/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/zh-CN/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/zh-CN/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/zh-HK/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/zh-HK/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								public/locales/zh-TW/drive.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								public/locales/zh-TW/drive.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "name": "Google Sync", | ||||||
|  |   "tagline": "Effortlessly synchronize your chats and settings with Google Drive.", | ||||||
|  |   "button": { | ||||||
|  |     "sync": "Sync your chats", | ||||||
|  |     "stop": "Stop syncing", | ||||||
|  |     "create": "Create new file", | ||||||
|  |     "confirm": "Confirm selection" | ||||||
|  |   }, | ||||||
|  |   "notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.", | ||||||
|  |   "privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.", | ||||||
|  |   "toast": { | ||||||
|  |     "sync": "Sync successful!", | ||||||
|  |     "stop": "Syncing stopped" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -9,6 +9,7 @@ import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; | |||||||
| import { ChatInterface } from '@type/chat'; | import { ChatInterface } from '@type/chat'; | ||||||
| import { Theme } from '@type/theme'; | import { Theme } from '@type/theme'; | ||||||
| import ApiPopup from '@components/ApiPopup'; | import ApiPopup from '@components/ApiPopup'; | ||||||
|  | import Toast from '@components/Toast'; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|   const initialiseNewChat = useInitialiseNewChat(); |   const initialiseNewChat = useInitialiseNewChat(); | ||||||
| @@ -78,6 +79,7 @@ function App() { | |||||||
|       <Menu /> |       <Menu /> | ||||||
|       <Chat /> |       <Chat /> | ||||||
|       <ApiPopup /> |       <ApiPopup /> | ||||||
|  |       <Toast /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										191
									
								
								src/api/google-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								src/api/google-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | |||||||
|  | import { debounce } from 'lodash'; | ||||||
|  | import { StorageValue } from 'zustand/middleware'; | ||||||
|  | import useStore from '@store/store'; | ||||||
|  | import useCloudAuthStore from '@store/cloud-auth-store'; | ||||||
|  | import { | ||||||
|  |   GoogleTokenInfo, | ||||||
|  |   GoogleFileResource, | ||||||
|  |   GoogleFileList, | ||||||
|  | } from '@type/google-api'; | ||||||
|  | import PersistStorageState from '@type/persist'; | ||||||
|  |  | ||||||
|  | import { createMultipartRelatedBody } from './helper'; | ||||||
|  |  | ||||||
|  | export const createDriveFile = async ( | ||||||
|  |   file: File, | ||||||
|  |   accessToken: string | ||||||
|  | ): Promise<GoogleFileResource> => { | ||||||
|  |   const boundary = 'better_chatgpt'; | ||||||
|  |   const metadata = { | ||||||
|  |     name: file.name, | ||||||
|  |     mimeType: file.type, | ||||||
|  |   }; | ||||||
|  |   const requestBody = createMultipartRelatedBody(metadata, file, boundary); | ||||||
|  |  | ||||||
|  |   const response = await fetch( | ||||||
|  |     'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', | ||||||
|  |     { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |         'Content-Type': `multipart/related; boundary=${boundary}`, | ||||||
|  |         'Content-Length': requestBody.size.toString(), | ||||||
|  |       }, | ||||||
|  |       body: requestBody, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (response.ok) { | ||||||
|  |     const result: GoogleFileResource = await response.json(); | ||||||
|  |     return result; | ||||||
|  |   } else { | ||||||
|  |     throw new Error( | ||||||
|  |       `Error uploading file: ${response.status} ${response.statusText}` | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const getDriveFile = async <S>( | ||||||
|  |   fileId: string, | ||||||
|  |   accessToken: string | ||||||
|  | ): Promise<StorageValue<S>> => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     `https://content.googleapis.com/drive/v3/files/${fileId}?alt=media`, | ||||||
|  |     { | ||||||
|  |       method: 'GET', | ||||||
|  |       headers: { | ||||||
|  |         'Content-Type': 'application/json', | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |       }, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |   const result: StorageValue<S> = await response.json(); | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const getDriveFileTyped = async ( | ||||||
|  |   fileId: string, | ||||||
|  |   accessToken: string | ||||||
|  | ): Promise<StorageValue<PersistStorageState>> => { | ||||||
|  |   return await getDriveFile(fileId, accessToken); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const listDriveFiles = async ( | ||||||
|  |   accessToken: string | ||||||
|  | ): Promise<GoogleFileList> => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     'https://www.googleapis.com/drive/v3/files?orderBy=createdTime desc', | ||||||
|  |     { | ||||||
|  |       method: 'GET', | ||||||
|  |       headers: { | ||||||
|  |         'Content-Type': 'application/json', | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |       }, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error( | ||||||
|  |       `Error listing google drive files: ${response.status} ${response.statusText}` | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const result: GoogleFileList = await response.json(); | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const updateDriveFile = async ( | ||||||
|  |   file: File, | ||||||
|  |   fileId: string, | ||||||
|  |   accessToken: string | ||||||
|  | ): Promise<GoogleFileResource> => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     `https://www.googleapis.com/upload/drive/v3/files/${fileId}`, | ||||||
|  |     { | ||||||
|  |       method: 'PATCH', | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |       }, | ||||||
|  |       body: file, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |   if (response.ok) { | ||||||
|  |     const result: GoogleFileResource = await response.json(); | ||||||
|  |     return result; | ||||||
|  |   } else { | ||||||
|  |     throw new Error( | ||||||
|  |       `Error uploading file: ${response.status} ${response.statusText}` | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const updateDriveFileName = async ( | ||||||
|  |   fileName: string, | ||||||
|  |   fileId: string, | ||||||
|  |   accessToken: string | ||||||
|  | ) => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     `https://www.googleapis.com/drive/v3/files/${fileId}`, | ||||||
|  |     { | ||||||
|  |       method: 'PATCH', | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |       }, | ||||||
|  |       body: JSON.stringify({ name: fileName }), | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |   if (response.ok) { | ||||||
|  |     const result: GoogleFileResource = await response.json(); | ||||||
|  |     return result; | ||||||
|  |   } else { | ||||||
|  |     throw new Error( | ||||||
|  |       `Error updating file name: ${response.status} ${response.statusText}` | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const deleteDriveFile = async (fileId: string, accessToken: string) => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     `https://www.googleapis.com/drive/v3/files/${fileId}`, | ||||||
|  |     { | ||||||
|  |       method: 'DELETE', | ||||||
|  |       headers: { | ||||||
|  |         'Content-Type': 'application/json', | ||||||
|  |         Authorization: `Bearer ${accessToken}`, | ||||||
|  |       }, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (response.ok) { | ||||||
|  |     return true; | ||||||
|  |   } else { | ||||||
|  |     throw new Error( | ||||||
|  |       `Error deleting file name: ${response.status} ${response.statusText}` | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const validateGoogleOath2AccessToken = async (accessToken: string) => { | ||||||
|  |   const response = await fetch( | ||||||
|  |     `https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}` | ||||||
|  |   ); | ||||||
|  |   if (!response.ok) return false; | ||||||
|  |   const result: GoogleTokenInfo = await response.json(); | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const updateDriveFileDebounced = debounce( | ||||||
|  |   async (file: File, fileId: string, accessToken: string) => { | ||||||
|  |     try { | ||||||
|  |       const result = await updateDriveFile(file, fileId, accessToken); | ||||||
|  |       useCloudAuthStore.getState().setSyncStatus('synced'); | ||||||
|  |       return result; | ||||||
|  |     } catch (e: unknown) { | ||||||
|  |       useStore.getState().setToastMessage((e as Error).message); | ||||||
|  |       useStore.getState().setToastShow(true); | ||||||
|  |       useStore.getState().setToastStatus('error'); | ||||||
|  |       useCloudAuthStore.getState().setSyncStatus('unauthenticated'); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   5000 | ||||||
|  | ); | ||||||
| @@ -21,3 +21,25 @@ export const parseEventSource = ( | |||||||
|     }); |     }); | ||||||
|   return result; |   return result; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const createMultipartRelatedBody = ( | ||||||
|  |   metadata: object, | ||||||
|  |   file: File, | ||||||
|  |   boundary: string | ||||||
|  | ): Blob => { | ||||||
|  |   const encoder = new TextEncoder(); | ||||||
|  |  | ||||||
|  |   const metadataPart = encoder.encode( | ||||||
|  |     `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify( | ||||||
|  |       metadata | ||||||
|  |     )}\r\n` | ||||||
|  |   ); | ||||||
|  |   const filePart = encoder.encode( | ||||||
|  |     `--${boundary}\r\nContent-Type: ${file.type}\r\n\r\n` | ||||||
|  |   ); | ||||||
|  |   const endBoundary = encoder.encode(`\r\n--${boundary}--`); | ||||||
|  |  | ||||||
|  |   return new Blob([metadataPart, filePart, file, endBoundary], { | ||||||
|  |     type: 'multipart/related; boundary=' + boundary, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								src/assets/icons/GoogleIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/assets/icons/GoogleIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | import React from 'react'; | ||||||
|  |  | ||||||
|  | const GoogleIcon = (props: React.SVGProps<SVGSVGElement>) => { | ||||||
|  |   return ( | ||||||
|  |     <svg | ||||||
|  |       fill='currentColor' | ||||||
|  |       viewBox='0 0 16 16' | ||||||
|  |       height='1em' | ||||||
|  |       width='1em' | ||||||
|  |       {...props} | ||||||
|  |     > | ||||||
|  |       <path d='M15.545 6.558a9.42 9.42 0 01.139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 118 0a7.689 7.689 0 015.352 2.082l-2.284 2.284A4.347 4.347 0 008 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.792 4.792 0 000 3.063h.003c.635 1.893 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.702 3.702 0 001.599-2.431H8v-3.08h7.545z' /> | ||||||
|  |     </svg> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default GoogleIcon; | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  |  | ||||||
| const RefreshIcon = () => { | const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => { | ||||||
|   return ( |   return ( | ||||||
|     <svg |     <svg | ||||||
|       stroke='currentColor' |       stroke='currentColor' | ||||||
| @@ -13,6 +13,7 @@ const RefreshIcon = () => { | |||||||
|       height='1em' |       height='1em' | ||||||
|       width='1em' |       width='1em' | ||||||
|       xmlns='http://www.w3.org/2000/svg' |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |       {...props} | ||||||
|     > |     > | ||||||
|       <polyline points='1 4 1 10 7 10'></polyline> |       <polyline points='1 4 1 10 7 10'></polyline> | ||||||
|       <polyline points='23 20 23 14 17 14'></polyline> |       <polyline points='23 20 23 14 17 14'></polyline> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  |  | ||||||
| const TickIcon = () => { | const TickIcon = (props: React.SVGProps<SVGSVGElement>) => { | ||||||
|   return ( |   return ( | ||||||
|     <svg |     <svg | ||||||
|       stroke='currentColor' |       stroke='currentColor' | ||||||
| @@ -13,6 +13,7 @@ const TickIcon = () => { | |||||||
|       height='1em' |       height='1em' | ||||||
|       width='1em' |       width='1em' | ||||||
|       xmlns='http://www.w3.org/2000/svg' |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |       {...props} | ||||||
|     > |     > | ||||||
|       <polyline points='20 6 9 17 4 12'></polyline> |       <polyline points='20 6 9 17 4 12'></polyline> | ||||||
|     </svg> |     </svg> | ||||||
|   | |||||||
							
								
								
									
										379
									
								
								src/components/GoogleSync/GoogleSync.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										379
									
								
								src/components/GoogleSync/GoogleSync.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,379 @@ | |||||||
|  | import React, { useEffect, useState } from 'react'; | ||||||
|  | import { GoogleOAuthProvider } from '@react-oauth/google'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
|  |  | ||||||
|  | import useStore from '@store/store'; | ||||||
|  | import useGStore from '@store/cloud-auth-store'; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   createDriveFile, | ||||||
|  |   deleteDriveFile, | ||||||
|  |   updateDriveFileName, | ||||||
|  |   validateGoogleOath2AccessToken, | ||||||
|  | } from '@api/google-api'; | ||||||
|  | import { getFiles, stateToFile } from '@utils/google-api'; | ||||||
|  | import createGoogleCloudStorage from '@store/storage/GoogleCloudStorage'; | ||||||
|  |  | ||||||
|  | import GoogleSyncButton from './GoogleSyncButton'; | ||||||
|  | import PopupModal from '@components/PopupModal'; | ||||||
|  |  | ||||||
|  | import GoogleIcon from '@icon/GoogleIcon'; | ||||||
|  | import TickIcon from '@icon/TickIcon'; | ||||||
|  | import RefreshIcon from '@icon/RefreshIcon'; | ||||||
|  |  | ||||||
|  | import { GoogleFileResource, SyncStatus } from '@type/google-api'; | ||||||
|  | import EditIcon from '@icon/EditIcon'; | ||||||
|  | import CrossIcon from '@icon/CrossIcon'; | ||||||
|  | import DeleteIcon from '@icon/DeleteIcon'; | ||||||
|  |  | ||||||
|  | const GoogleSync = ({ clientId }: { clientId: string }) => { | ||||||
|  |   const { t } = useTranslation(['drive']); | ||||||
|  |  | ||||||
|  |   const fileId = useGStore((state) => state.fileId); | ||||||
|  |   const setFileId = useGStore((state) => state.setFileId); | ||||||
|  |   const googleAccessToken = useGStore((state) => state.googleAccessToken); | ||||||
|  |   const syncStatus = useGStore((state) => state.syncStatus); | ||||||
|  |   const cloudSync = useGStore((state) => state.cloudSync); | ||||||
|  |   const setSyncStatus = useGStore((state) => state.setSyncStatus); | ||||||
|  |  | ||||||
|  |   const [isModalOpen, setIsModalOpen] = useState<boolean>(cloudSync); | ||||||
|  |   const [files, setFiles] = useState<GoogleFileResource[]>([]); | ||||||
|  |  | ||||||
|  |   const initialiseState = async (_googleAccessToken: string) => { | ||||||
|  |     const validated = await validateGoogleOath2AccessToken(_googleAccessToken); | ||||||
|  |     if (validated) { | ||||||
|  |       try { | ||||||
|  |         const _files = await getFiles(_googleAccessToken); | ||||||
|  |         if (_files) { | ||||||
|  |           setFiles(_files); | ||||||
|  |           if (_files.length === 0) { | ||||||
|  |             // _files is empty, create new file in google drive and set the file id | ||||||
|  |             const googleFile = await createDriveFile( | ||||||
|  |               stateToFile(), | ||||||
|  |               _googleAccessToken | ||||||
|  |             ); | ||||||
|  |             setFileId(googleFile.id); | ||||||
|  |           } else { | ||||||
|  |             if (_files.findIndex((f) => f.id === fileId) !== -1) { | ||||||
|  |               // local storage file id matches one of the file ids returned | ||||||
|  |               setFileId(fileId); | ||||||
|  |             } else { | ||||||
|  |               // default set file id to the latest one | ||||||
|  |               setFileId(_files[0].id); | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |           useStore.persist.setOptions({ | ||||||
|  |             storage: createGoogleCloudStorage(), | ||||||
|  |           }); | ||||||
|  |           useStore.persist.rehydrate(); | ||||||
|  |         } | ||||||
|  |       } catch (e: unknown) { | ||||||
|  |         console.log(e); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       setSyncStatus('unauthenticated'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (googleAccessToken) { | ||||||
|  |       setSyncStatus('syncing'); | ||||||
|  |       initialiseState(googleAccessToken); | ||||||
|  |     } | ||||||
|  |   }, [googleAccessToken]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <GoogleOAuthProvider clientId={clientId}> | ||||||
|  |       <div | ||||||
|  |         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); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         <GoogleIcon /> {t('name')} | ||||||
|  |         {cloudSync && <SyncIcon status={syncStatus} />} | ||||||
|  |       </div> | ||||||
|  |       {isModalOpen && ( | ||||||
|  |         <GooglePopup | ||||||
|  |           setIsModalOpen={setIsModalOpen} | ||||||
|  |           files={files} | ||||||
|  |           setFiles={setFiles} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |     </GoogleOAuthProvider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const GooglePopup = ({ | ||||||
|  |   setIsModalOpen, | ||||||
|  |   files, | ||||||
|  |   setFiles, | ||||||
|  | }: { | ||||||
|  |   setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>; | ||||||
|  |   files: GoogleFileResource[]; | ||||||
|  |   setFiles: React.Dispatch<React.SetStateAction<GoogleFileResource[]>>; | ||||||
|  | }) => { | ||||||
|  |   const { t } = useTranslation(['drive']); | ||||||
|  |  | ||||||
|  |   const syncStatus = useGStore((state) => state.syncStatus); | ||||||
|  |   const setSyncStatus = useGStore((state) => state.setSyncStatus); | ||||||
|  |   const cloudSync = useGStore((state) => state.cloudSync); | ||||||
|  |   const googleAccessToken = useGStore((state) => state.googleAccessToken); | ||||||
|  |   const setFileId = useGStore((state) => state.setFileId); | ||||||
|  |  | ||||||
|  |   const setToastStatus = useStore((state) => state.setToastStatus); | ||||||
|  |   const setToastMessage = useStore((state) => state.setToastMessage); | ||||||
|  |   const setToastShow = useStore((state) => state.setToastShow); | ||||||
|  |  | ||||||
|  |   const [_fileId, _setFileId] = useState<string>( | ||||||
|  |     useGStore.getState().fileId || '' | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const createSyncFile = async () => { | ||||||
|  |     if (!googleAccessToken) return; | ||||||
|  |     try { | ||||||
|  |       setSyncStatus('syncing'); | ||||||
|  |       await createDriveFile(stateToFile(), googleAccessToken); | ||||||
|  |       const _files = await getFiles(googleAccessToken); | ||||||
|  |       if (_files) setFiles(_files); | ||||||
|  |       setSyncStatus('synced'); | ||||||
|  |     } catch (e: unknown) { | ||||||
|  |       setSyncStatus('unauthenticated'); | ||||||
|  |       setToastMessage((e as Error).message); | ||||||
|  |       setToastShow(true); | ||||||
|  |       setToastStatus('error'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <PopupModal | ||||||
|  |       title={t('name') as string} | ||||||
|  |       setIsModalOpen={setIsModalOpen} | ||||||
|  |       cancelButton={false} | ||||||
|  |     > | ||||||
|  |       <div className='p-6 border-b border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-300 text-sm flex flex-col items-center gap-4 text-center'> | ||||||
|  |         <p>{t('tagline')}</p> | ||||||
|  |         <GoogleSyncButton | ||||||
|  |           loginHandler={() => { | ||||||
|  |             setIsModalOpen(false); | ||||||
|  |             window.setTimeout(() => { | ||||||
|  |               setIsModalOpen(true); | ||||||
|  |             }, 3540000); // timeout - 3540000ms = 59 min (access token last 60 min) | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |         <p className='border border-gray-400 px-3 py-2 rounded-md'> | ||||||
|  |           {t('notice')} | ||||||
|  |         </p> | ||||||
|  |         {cloudSync && syncStatus !== 'unauthenticated' && ( | ||||||
|  |           <div className='flex flex-col gap-2 items-center'> | ||||||
|  |             {files.map((file) => ( | ||||||
|  |               <FileSelector | ||||||
|  |                 id={file.id} | ||||||
|  |                 name={file.name} | ||||||
|  |                 _fileId={_fileId} | ||||||
|  |                 _setFileId={_setFileId} | ||||||
|  |                 setFiles={setFiles} | ||||||
|  |                 key={file.id} | ||||||
|  |               /> | ||||||
|  |             ))} | ||||||
|  |             {syncStatus !== 'syncing' && ( | ||||||
|  |               <div className='flex gap-4 flex-wrap justify-center'> | ||||||
|  |                 <div | ||||||
|  |                   className='btn btn-primary cursor-pointer' | ||||||
|  |                   onClick={async () => { | ||||||
|  |                     setFileId(_fileId); | ||||||
|  |                     await useStore.persist.rehydrate(); | ||||||
|  |                     setToastStatus('success'); | ||||||
|  |                     setToastMessage(t('toast.sync')); | ||||||
|  |                     setToastShow(true); | ||||||
|  |                     setIsModalOpen(false); | ||||||
|  |                   }} | ||||||
|  |                 > | ||||||
|  |                   {t('button.confirm')} | ||||||
|  |                 </div> | ||||||
|  |                 <div | ||||||
|  |                   className='btn btn-neutral cursor-pointer' | ||||||
|  |                   onClick={createSyncFile} | ||||||
|  |                 > | ||||||
|  |                   {t('button.create')} | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|  |             <div className='h-4 w-4'> | ||||||
|  |               {syncStatus === 'syncing' && <SyncIcon status='syncing' />} | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |         <p>{t('privacy')}</p> | ||||||
|  |       </div> | ||||||
|  |     </PopupModal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const FileSelector = ({ | ||||||
|  |   name, | ||||||
|  |   id, | ||||||
|  |   _fileId, | ||||||
|  |   _setFileId, | ||||||
|  |   setFiles, | ||||||
|  | }: { | ||||||
|  |   name: string; | ||||||
|  |   id: string; | ||||||
|  |   _fileId: string; | ||||||
|  |   _setFileId: React.Dispatch<React.SetStateAction<string>>; | ||||||
|  |   setFiles: React.Dispatch<React.SetStateAction<GoogleFileResource[]>>; | ||||||
|  | }) => { | ||||||
|  |   const syncStatus = useGStore((state) => state.syncStatus); | ||||||
|  |   const setSyncStatus = useGStore((state) => state.setSyncStatus); | ||||||
|  |  | ||||||
|  |   const setToastStatus = useStore((state) => state.setToastStatus); | ||||||
|  |   const setToastMessage = useStore((state) => state.setToastMessage); | ||||||
|  |   const setToastShow = useStore((state) => state.setToastShow); | ||||||
|  |  | ||||||
|  |   const [isEditing, setIsEditing] = useState<boolean>(false); | ||||||
|  |   const [isDeleting, setIsDeleting] = useState<boolean>(false); | ||||||
|  |   const [_name, _setName] = useState<string>(name); | ||||||
|  |  | ||||||
|  |   const syncing = syncStatus === 'syncing'; | ||||||
|  |  | ||||||
|  |   const updateFileName = async () => { | ||||||
|  |     if (syncing) return; | ||||||
|  |     setIsEditing(false); | ||||||
|  |     const accessToken = useGStore.getState().googleAccessToken; | ||||||
|  |     if (!accessToken) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       setSyncStatus('syncing'); | ||||||
|  |       const newFileName = _name.endsWith('.json') ? _name : _name + '.json'; | ||||||
|  |       await updateDriveFileName(newFileName, id, accessToken); | ||||||
|  |       const _files = await getFiles(accessToken); | ||||||
|  |       if (_files) setFiles(_files); | ||||||
|  |       setSyncStatus('synced'); | ||||||
|  |     } catch (e: unknown) { | ||||||
|  |       setSyncStatus('unauthenticated'); | ||||||
|  |       setToastMessage((e as Error).message); | ||||||
|  |       setToastShow(true); | ||||||
|  |       setToastStatus('error'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const deleteFile = async () => { | ||||||
|  |     if (syncing) return; | ||||||
|  |     setIsDeleting(false); | ||||||
|  |     const accessToken = useGStore.getState().googleAccessToken; | ||||||
|  |     if (!accessToken) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       setSyncStatus('syncing'); | ||||||
|  |       await deleteDriveFile(id, accessToken); | ||||||
|  |       const _files = await getFiles(accessToken); | ||||||
|  |       if (_files) setFiles(_files); | ||||||
|  |       setSyncStatus('synced'); | ||||||
|  |     } catch (e: unknown) { | ||||||
|  |       setSyncStatus('unauthenticated'); | ||||||
|  |       setToastMessage((e as Error).message); | ||||||
|  |       setToastShow(true); | ||||||
|  |       setToastStatus('error'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <label | ||||||
|  |       className={`w-full flex items-center justify-between mb-2 gap-2 text-sm font-medium text-gray-900 dark:text-gray-300 ${ | ||||||
|  |         syncing ? 'cursor-not-allowed opacity-40' : '' | ||||||
|  |       }`} | ||||||
|  |     > | ||||||
|  |       <input | ||||||
|  |         type='radio' | ||||||
|  |         checked={_fileId === id} | ||||||
|  |         className='w-4 h-4' | ||||||
|  |         onChange={() => { | ||||||
|  |           if (!syncing) _setFileId(id); | ||||||
|  |         }} | ||||||
|  |         disabled={syncing} | ||||||
|  |       /> | ||||||
|  |       <div className='flex-1 text-left'> | ||||||
|  |         {isEditing ? ( | ||||||
|  |           <input | ||||||
|  |             type='text' | ||||||
|  |             className='text-gray-800 dark:text-white p-3 text-sm border-none bg-gray-200 dark:bg-gray-600 rounded-md m-0 w-full mr-0 h-8 focus:outline-none' | ||||||
|  |             value={_name} | ||||||
|  |             onChange={(e) => { | ||||||
|  |               _setName(e.target.value); | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         ) : ( | ||||||
|  |           <> | ||||||
|  |             {name} <div className='text-[10px] md:text-xs'>{`<${id}>`}</div> | ||||||
|  |           </> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |       {isEditing || isDeleting ? ( | ||||||
|  |         <div className='flex gap-1'> | ||||||
|  |           <div | ||||||
|  |             className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`} | ||||||
|  |             onClick={() => { | ||||||
|  |               if (isEditing) updateFileName(); | ||||||
|  |               if (isDeleting) deleteFile(); | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             <TickIcon /> | ||||||
|  |           </div> | ||||||
|  |           <div | ||||||
|  |             className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`} | ||||||
|  |             onClick={() => { | ||||||
|  |               if (!syncing) { | ||||||
|  |                 setIsEditing(false); | ||||||
|  |                 setIsDeleting(false); | ||||||
|  |               } | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             <CrossIcon /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       ) : ( | ||||||
|  |         <div className='flex gap-1'> | ||||||
|  |           <div | ||||||
|  |             className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`} | ||||||
|  |             onClick={() => { | ||||||
|  |               if (!syncing) setIsEditing(true); | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             <EditIcon /> | ||||||
|  |           </div> | ||||||
|  |           <div | ||||||
|  |             className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`} | ||||||
|  |             onClick={() => { | ||||||
|  |               if (!syncing) setIsDeleting(true); | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             <DeleteIcon /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </label> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const SyncIcon = ({ status }: { status: SyncStatus }) => { | ||||||
|  |   const statusToIcon = { | ||||||
|  |     unauthenticated: ( | ||||||
|  |       <div className='bg-red-600/80 rounded-full w-4 h-4 text-xs flex justify-center items-center'> | ||||||
|  |         ! | ||||||
|  |       </div> | ||||||
|  |     ), | ||||||
|  |     syncing: ( | ||||||
|  |       <div className='bg-gray-600/80 rounded-full p-1 animate-spin'> | ||||||
|  |         <RefreshIcon className='h-2 w-2' /> | ||||||
|  |       </div> | ||||||
|  |     ), | ||||||
|  |     synced: ( | ||||||
|  |       <div className='bg-gray-600/80 rounded-full p-1'> | ||||||
|  |         <TickIcon className='h-2 w-2' /> | ||||||
|  |       </div> | ||||||
|  |     ), | ||||||
|  |   }; | ||||||
|  |   return statusToIcon[status] || null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default GoogleSync; | ||||||
							
								
								
									
										67
									
								
								src/components/GoogleSync/GoogleSyncButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/GoogleSync/GoogleSyncButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | import React, { useEffect } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
|  |  | ||||||
|  | import { useGoogleLogin, googleLogout } from '@react-oauth/google'; | ||||||
|  | import useGStore from '@store/cloud-auth-store'; | ||||||
|  | import useStore from '@store/store'; | ||||||
|  | import { createJSONStorage } from 'zustand/middleware'; | ||||||
|  |  | ||||||
|  | const GoogleSyncButton = ({ loginHandler }: { loginHandler?: () => void }) => { | ||||||
|  |   const { t } = useTranslation(['drive']); | ||||||
|  |  | ||||||
|  |   const setGoogleAccessToken = useGStore((state) => state.setGoogleAccessToken); | ||||||
|  |   const setSyncStatus = useGStore((state) => state.setSyncStatus); | ||||||
|  |   const setCloudSync = useGStore((state) => state.setCloudSync); | ||||||
|  |   const cloudSync = useGStore((state) => state.cloudSync); | ||||||
|  |  | ||||||
|  |   const setToastStatus = useStore((state) => state.setToastStatus); | ||||||
|  |   const setToastMessage = useStore((state) => state.setToastMessage); | ||||||
|  |   const setToastShow = useStore((state) => state.setToastShow); | ||||||
|  |  | ||||||
|  |   const login = useGoogleLogin({ | ||||||
|  |     onSuccess: (codeResponse) => { | ||||||
|  |       setGoogleAccessToken(codeResponse.access_token); | ||||||
|  |       setCloudSync(true); | ||||||
|  |       loginHandler && loginHandler(); | ||||||
|  |       setToastStatus('success'); | ||||||
|  |       setToastMessage(t('toast.sync')); | ||||||
|  |       setToastShow(true); | ||||||
|  |     }, | ||||||
|  |     onError: (error) => { | ||||||
|  |       console.log('Login Failed'); | ||||||
|  |       setToastStatus('error'); | ||||||
|  |       setToastMessage(error?.error_description || 'Error in authenticating!'); | ||||||
|  |       setToastShow(true); | ||||||
|  |     }, | ||||||
|  |     scope: 'https://www.googleapis.com/auth/drive.file', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const logout = () => { | ||||||
|  |     setGoogleAccessToken(undefined); | ||||||
|  |     setSyncStatus('unauthenticated'); | ||||||
|  |     setCloudSync(false); | ||||||
|  |     googleLogout(); | ||||||
|  |     useStore.persist.setOptions({ | ||||||
|  |       storage: createJSONStorage(() => localStorage), | ||||||
|  |     }); | ||||||
|  |     useStore.persist.rehydrate(); | ||||||
|  |     setToastStatus('success'); | ||||||
|  |     setToastMessage(t('toast.stop')); | ||||||
|  |     setToastShow(true); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className='flex gap-4 flex-wrap justify-center'> | ||||||
|  |       <button className='btn btn-primary' onClick={() => login()}> | ||||||
|  |         {t('button.sync')} | ||||||
|  |       </button> | ||||||
|  |       {cloudSync && ( | ||||||
|  |         <button className='btn btn-neutral' onClick={logout}> | ||||||
|  |           {t('button.stop')} | ||||||
|  |         </button> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default GoogleSyncButton; | ||||||
							
								
								
									
										1
									
								
								src/components/GoogleSync/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/GoogleSync/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export { default } from './GoogleSync'; | ||||||
| @@ -27,9 +27,9 @@ const ChatFolder = ({ | |||||||
|   folderChats: ChatHistoryInterface[]; |   folderChats: ChatHistoryInterface[]; | ||||||
|   folderId: string; |   folderId: string; | ||||||
| }) => { | }) => { | ||||||
|   const folderName = useStore((state) => state.folders[folderId].name); |   const folderName = useStore((state) => state.folders[folderId]?.name); | ||||||
|   const isExpanded = useStore((state) => state.folders[folderId].expanded); |   const isExpanded = useStore((state) => state.folders[folderId]?.expanded); | ||||||
|   const color = useStore((state) => state.folders[folderId].color); |   const color = useStore((state) => state.folders[folderId]?.color); | ||||||
|  |  | ||||||
|   const setChats = useStore((state) => state.setChats); |   const setChats = useStore((state) => state.setChats); | ||||||
|   const setFolders = useStore((state) => state.setFolders); |   const setFolders = useStore((state) => state.setFolders); | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ import AboutMenu from '@components/AboutMenu'; | |||||||
| import ImportExportChat from '@components/ImportExportChat'; | import ImportExportChat from '@components/ImportExportChat'; | ||||||
| import SettingsMenu from '@components/SettingsMenu'; | import SettingsMenu from '@components/SettingsMenu'; | ||||||
| import CollapseOptions from './CollapseOptions'; | import CollapseOptions from './CollapseOptions'; | ||||||
|  | import GoogleSync from '@components/GoogleSync'; | ||||||
|  |  | ||||||
|  | const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined; | ||||||
|  |  | ||||||
| const MenuOptions = () => { | const MenuOptions = () => { | ||||||
|   const hideMenuOptions = useStore((state) => state.hideMenuOptions); |   const hideMenuOptions = useStore((state) => state.hideMenuOptions); | ||||||
| @@ -19,6 +22,7 @@ const MenuOptions = () => { | |||||||
|           hideMenuOptions ? 'max-h-0' : 'max-h-full' |           hideMenuOptions ? 'max-h-0' : 'max-h-full' | ||||||
|         } overflow-hidden transition-all`} |         } overflow-hidden transition-all`} | ||||||
|       > |       > | ||||||
|  |         {googleClientId && <GoogleSync clientId={googleClientId} />} | ||||||
|         <AboutMenu /> |         <AboutMenu /> | ||||||
|         <ClearConversation /> |         <ClearConversation /> | ||||||
|         <ImportExportChat /> |         <ImportExportChat /> | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import React, { useEffect, useState } from 'react'; | import React, { useEffect, useState } from 'react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import useStore from '@store/store'; | import useStore from '@store/store'; | ||||||
|  | import useCloudAuthStore from '@store/cloud-auth-store'; | ||||||
|  |  | ||||||
| import PopupModal from '@components/PopupModal'; | import PopupModal from '@components/PopupModal'; | ||||||
| import SettingIcon from '@icon/SettingIcon'; | import SettingIcon from '@icon/SettingIcon'; | ||||||
| @@ -11,6 +12,7 @@ import PromptLibraryMenu from '@components/PromptLibraryMenu'; | |||||||
| import ChatConfigMenu from '@components/ChatConfigMenu'; | import ChatConfigMenu from '@components/ChatConfigMenu'; | ||||||
| import EnterToSubmitToggle from './EnterToSubmitToggle'; | import EnterToSubmitToggle from './EnterToSubmitToggle'; | ||||||
|  |  | ||||||
|  |  | ||||||
| const SettingsMenu = () => { | const SettingsMenu = () => { | ||||||
|   const { t } = useTranslation(); |   const { t } = useTranslation(); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										134
									
								
								src/components/Toast/Toast.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/components/Toast/Toast.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | |||||||
|  | import React, { useEffect, useState } from 'react'; | ||||||
|  | import useStore from '@store/store'; | ||||||
|  |  | ||||||
|  | export type ToastStatus = 'success' | 'error' | 'warning'; | ||||||
|  |  | ||||||
|  | const Toast = () => { | ||||||
|  |   const message = useStore((state) => state.toastMessage); | ||||||
|  |   const status = useStore((state) => state.toastStatus); | ||||||
|  |   const toastShow = useStore((state) => state.toastShow); | ||||||
|  |   const setToastShow = useStore((state) => state.setToastShow); | ||||||
|  |  | ||||||
|  |   const [timeoutID, setTimeoutID] = useState<number>(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (toastShow) { | ||||||
|  |       window.clearTimeout(timeoutID); | ||||||
|  |  | ||||||
|  |       const newTimeoutID = window.setTimeout(() => { | ||||||
|  |         setToastShow(false); | ||||||
|  |       }, 5000); | ||||||
|  |  | ||||||
|  |       setTimeoutID(newTimeoutID); | ||||||
|  |     } | ||||||
|  |   }, [toastShow, status, message]); | ||||||
|  |  | ||||||
|  |   return toastShow ? ( | ||||||
|  |     <div | ||||||
|  |       className={`flex fixed right-5 bottom-5 z-[1000] items-center w-3/4 md:w-full max-w-xs p-4 mb-4 text-gray-500 dark:text-gray-400 rounded-lg shadow-md border border-gray-400/30 animate-bounce`} | ||||||
|  |       role='alert' | ||||||
|  |     > | ||||||
|  |       <StatusIcon status={status} /> | ||||||
|  |       <div className='ml-3 text-sm font-normal'>{message}</div> | ||||||
|  |       <button | ||||||
|  |         type='button' | ||||||
|  |         className='ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700' | ||||||
|  |         aria-label='Close' | ||||||
|  |         onClick={() => { | ||||||
|  |           setToastShow(false); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         <CloseIcon /> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   ) : ( | ||||||
|  |     <></> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const StatusIcon = ({ status }: { status: ToastStatus }) => { | ||||||
|  |   const statusToIcon = { | ||||||
|  |     success: <CheckIcon />, | ||||||
|  |     error: <ErrorIcon />, | ||||||
|  |     warning: <WarningIcon />, | ||||||
|  |   }; | ||||||
|  |   return statusToIcon[status] || null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const CloseIcon = () => ( | ||||||
|  |   <> | ||||||
|  |     <span className='sr-only'>Close</span> | ||||||
|  |     <svg | ||||||
|  |       aria-hidden='true' | ||||||
|  |       className='w-5 h-5' | ||||||
|  |       fill='currentColor' | ||||||
|  |       viewBox='0 0 20 20' | ||||||
|  |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fillRule='evenodd' | ||||||
|  |         d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z' | ||||||
|  |         clipRule='evenodd' | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |   </> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const CheckIcon = () => ( | ||||||
|  |   <div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200'> | ||||||
|  |     <svg | ||||||
|  |       aria-hidden='true' | ||||||
|  |       className='w-5 h-5' | ||||||
|  |       fill='currentColor' | ||||||
|  |       viewBox='0 0 20 20' | ||||||
|  |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fillRule='evenodd' | ||||||
|  |         d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' | ||||||
|  |         clipRule='evenodd' | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |     <span className='sr-only'>Check icon</span> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const ErrorIcon = () => ( | ||||||
|  |   <div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200'> | ||||||
|  |     <svg | ||||||
|  |       aria-hidden='true' | ||||||
|  |       className='w-5 h-5' | ||||||
|  |       fill='currentColor' | ||||||
|  |       viewBox='0 0 20 20' | ||||||
|  |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fillRule='evenodd' | ||||||
|  |         d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z' | ||||||
|  |         clipRule='evenodd' | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |     <span className='sr-only'>Error icon</span> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const WarningIcon = () => ( | ||||||
|  |   <div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200'> | ||||||
|  |     <svg | ||||||
|  |       aria-hidden='true' | ||||||
|  |       className='w-5 h-5' | ||||||
|  |       fill='currentColor' | ||||||
|  |       viewBox='0 0 20 20' | ||||||
|  |       xmlns='http://www.w3.org/2000/svg' | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fillRule='evenodd' | ||||||
|  |         d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z' | ||||||
|  |         clipRule='evenodd' | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |     <span className='sr-only'>Warning icon</span> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export default Toast; | ||||||
							
								
								
									
										1
									
								
								src/components/Toast/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/Toast/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export { default } from './Toast'; | ||||||
							
								
								
									
										28
									
								
								src/i18n.ts
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								src/i18n.ts
									
									
									
									
									
								
							| @@ -4,6 +4,32 @@ import { initReactI18next } from 'react-i18next'; | |||||||
| import Backend from 'i18next-http-backend'; | import Backend from 'i18next-http-backend'; | ||||||
| import LanguageDetector from 'i18next-browser-languagedetector'; | import LanguageDetector from 'i18next-browser-languagedetector'; | ||||||
|  |  | ||||||
|  | const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined; | ||||||
|  |  | ||||||
|  | export const i18nLanguages = [ | ||||||
|  |   // 'ar', | ||||||
|  |   'da', | ||||||
|  |   'en', | ||||||
|  |   'en-GB', | ||||||
|  |   'en-US', | ||||||
|  |   'es', | ||||||
|  |   'fr', | ||||||
|  |   'fr-FR', | ||||||
|  |   'it', | ||||||
|  |   'ja', | ||||||
|  |   'ms', | ||||||
|  |   'nb', | ||||||
|  |   'sv', | ||||||
|  |   // 'ug', | ||||||
|  |   'yue', | ||||||
|  |   'zh-CN', | ||||||
|  |   'zh-HK', | ||||||
|  |   'zh-TW', | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | const namespace = ['main', 'api', 'about', 'model']; | ||||||
|  | if (googleClientId) namespace.push('drive'); | ||||||
|  |  | ||||||
| i18n | i18n | ||||||
|   .use(Backend) |   .use(Backend) | ||||||
|   .use(LanguageDetector) |   .use(LanguageDetector) | ||||||
| @@ -15,7 +41,7 @@ i18n | |||||||
|     fallbackLng: { |     fallbackLng: { | ||||||
|       default: ['en'], |       default: ['en'], | ||||||
|     }, |     }, | ||||||
|     ns: ['main', 'api', 'about', 'model'], |     ns: namespace, | ||||||
|     defaultNS: 'main', |     defaultNS: 'main', | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								src/store/cloud-auth-slice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/store/cloud-auth-slice.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | import { SyncStatus } from '@type/google-api'; | ||||||
|  | import { StoreSlice } from './cloud-auth-store'; | ||||||
|  |  | ||||||
|  | export interface CloudAuthSlice { | ||||||
|  |   googleAccessToken?: string; | ||||||
|  |   googleRefreshToken?: string; | ||||||
|  |   cloudSync: boolean; | ||||||
|  |   syncStatus: SyncStatus; | ||||||
|  |   fileId?: string; | ||||||
|  |   setGoogleAccessToken: (googleAccessToken?: string) => void; | ||||||
|  |   setGoogleRefreshToken: (googleRefreshToken?: string) => void; | ||||||
|  |   setFileId: (fileId?: string) => void; | ||||||
|  |   setCloudSync: (cloudSync: boolean) => void; | ||||||
|  |   setSyncStatus: (syncStatus: SyncStatus) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const createCloudAuthSlice: StoreSlice<CloudAuthSlice> = (set, get) => ({ | ||||||
|  |   cloudSync: false, | ||||||
|  |   syncStatus: 'unauthenticated', | ||||||
|  |   setGoogleAccessToken: (googleAccessToken?: string) => { | ||||||
|  |     set((prev: CloudAuthSlice) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       googleAccessToken: googleAccessToken, | ||||||
|  |     })); | ||||||
|  |   }, | ||||||
|  |   setGoogleRefreshToken: (googleRefreshToken?: string) => { | ||||||
|  |     set((prev: CloudAuthSlice) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       googleRefreshToken: googleRefreshToken, | ||||||
|  |     })); | ||||||
|  |   }, | ||||||
|  |   setFileId: (fileId?: string) => { | ||||||
|  |     set((prev: CloudAuthSlice) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       fileId: fileId, | ||||||
|  |     })); | ||||||
|  |   }, | ||||||
|  |   setCloudSync: (cloudSync: boolean) => { | ||||||
|  |     set((prev: CloudAuthSlice) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       cloudSync: cloudSync, | ||||||
|  |     })); | ||||||
|  |   }, | ||||||
|  |   setSyncStatus: (syncStatus: SyncStatus) => { | ||||||
|  |     set((prev: CloudAuthSlice) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       syncStatus: syncStatus, | ||||||
|  |     })); | ||||||
|  |   }, | ||||||
|  | }); | ||||||
							
								
								
									
										28
									
								
								src/store/cloud-auth-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/store/cloud-auth-store.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import { StoreApi, create } from 'zustand'; | ||||||
|  | import { persist } from 'zustand/middleware'; | ||||||
|  | import { CloudAuthSlice, createCloudAuthSlice } from './cloud-auth-slice'; | ||||||
|  |  | ||||||
|  | export type StoreState = CloudAuthSlice; | ||||||
|  |  | ||||||
|  | export type StoreSlice<T> = ( | ||||||
|  |   set: StoreApi<StoreState>['setState'], | ||||||
|  |   get: StoreApi<StoreState>['getState'] | ||||||
|  | ) => T; | ||||||
|  |  | ||||||
|  | const useCloudAuthStore = create<StoreState>()( | ||||||
|  |   persist( | ||||||
|  |     (set, get) => ({ | ||||||
|  |       ...createCloudAuthSlice(set, get), | ||||||
|  |     }), | ||||||
|  |     { | ||||||
|  |       name: 'cloud', | ||||||
|  |       partialize: (state) => ({ | ||||||
|  |         cloudSync: state.cloudSync, | ||||||
|  |         fileId: state.fileId, | ||||||
|  |       }), | ||||||
|  |       version: 1, | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export default useCloudAuthStore; | ||||||
							
								
								
									
										72
									
								
								src/store/storage/GoogleCloudStorage.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/store/storage/GoogleCloudStorage.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | |||||||
|  | import { PersistStorage, StorageValue, StateStorage } from 'zustand/middleware'; | ||||||
|  | import useCloudAuthStore from '@store/cloud-auth-store'; | ||||||
|  | import useStore from '@store/store'; | ||||||
|  | import { | ||||||
|  |   deleteDriveFile, | ||||||
|  |   getDriveFile, | ||||||
|  |   updateDriveFileDebounced, | ||||||
|  |   validateGoogleOath2AccessToken, | ||||||
|  | } from '@api/google-api'; | ||||||
|  |  | ||||||
|  | const createGoogleCloudStorage = <S>(): PersistStorage<S> | undefined => { | ||||||
|  |   const accessToken = useCloudAuthStore.getState().googleAccessToken; | ||||||
|  |   const fileId = useCloudAuthStore.getState().fileId; | ||||||
|  |   if (!accessToken || !fileId) return; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const authenticated = validateGoogleOath2AccessToken(accessToken); | ||||||
|  |     if (!authenticated) return; | ||||||
|  |   } catch (e) { | ||||||
|  |     // prevent error if the storage is not defined (e.g. when server side rendering a page) | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   const persistStorage: PersistStorage<S> = { | ||||||
|  |     getItem: async (name) => { | ||||||
|  |       useCloudAuthStore.getState().setSyncStatus('syncing'); | ||||||
|  |       try { | ||||||
|  |         const accessToken = useCloudAuthStore.getState().googleAccessToken; | ||||||
|  |         const fileId = useCloudAuthStore.getState().fileId; | ||||||
|  |         if (!accessToken || !fileId) return null; | ||||||
|  |  | ||||||
|  |         const data: StorageValue<S> = await getDriveFile(fileId, accessToken); | ||||||
|  |         useCloudAuthStore.getState().setSyncStatus('synced'); | ||||||
|  |         return data; | ||||||
|  |       } catch (e: unknown) { | ||||||
|  |         useCloudAuthStore.getState().setSyncStatus('unauthenticated'); | ||||||
|  |         useStore.getState().setToastMessage((e as Error).message); | ||||||
|  |         useStore.getState().setToastShow(true); | ||||||
|  |         useStore.getState().setToastStatus('error'); | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     setItem: async (name, newValue): Promise<void> => { | ||||||
|  |       const accessToken = useCloudAuthStore.getState().googleAccessToken; | ||||||
|  |       const fileId = useCloudAuthStore.getState().fileId; | ||||||
|  |       if (!accessToken || !fileId) return; | ||||||
|  |  | ||||||
|  |       const blob = new Blob([JSON.stringify(newValue)], { | ||||||
|  |         type: 'application/json', | ||||||
|  |       }); | ||||||
|  |       const file = new File([blob], 'better-chatgpt.json', { | ||||||
|  |         type: 'application/json', | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (useCloudAuthStore.getState().syncStatus !== 'unauthenticated') { | ||||||
|  |         useCloudAuthStore.getState().setSyncStatus('syncing'); | ||||||
|  |  | ||||||
|  |         await updateDriveFileDebounced(file, fileId, accessToken); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     removeItem: async (name): Promise<void> => { | ||||||
|  |       const accessToken = useCloudAuthStore.getState().googleAccessToken; | ||||||
|  |       const fileId = useCloudAuthStore.getState().fileId; | ||||||
|  |       if (!accessToken || !fileId) return; | ||||||
|  |  | ||||||
|  |       await deleteDriveFile(accessToken, fileId); | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  |   return persistStorage; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default createGoogleCloudStorage; | ||||||
| @@ -5,6 +5,7 @@ import { InputSlice, createInputSlice } from './input-slice'; | |||||||
| import { AuthSlice, createAuthSlice } from './auth-slice'; | import { AuthSlice, createAuthSlice } from './auth-slice'; | ||||||
| import { ConfigSlice, createConfigSlice } from './config-slice'; | import { ConfigSlice, createConfigSlice } from './config-slice'; | ||||||
| import { PromptSlice, createPromptSlice } from './prompt-slice'; | import { PromptSlice, createPromptSlice } from './prompt-slice'; | ||||||
|  | import { ToastSlice, createToastSlice } from './toast-slice'; | ||||||
| import { | import { | ||||||
|   LocalStorageInterfaceV0ToV1, |   LocalStorageInterfaceV0ToV1, | ||||||
|   LocalStorageInterfaceV1ToV2, |   LocalStorageInterfaceV1ToV2, | ||||||
| @@ -30,13 +31,31 @@ export type StoreState = ChatSlice & | |||||||
|   InputSlice & |   InputSlice & | ||||||
|   AuthSlice & |   AuthSlice & | ||||||
|   ConfigSlice & |   ConfigSlice & | ||||||
|   PromptSlice; |   PromptSlice & | ||||||
|  |   ToastSlice; | ||||||
|  |  | ||||||
| export type StoreSlice<T> = ( | export type StoreSlice<T> = ( | ||||||
|   set: StoreApi<StoreState>['setState'], |   set: StoreApi<StoreState>['setState'], | ||||||
|   get: StoreApi<StoreState>['getState'] |   get: StoreApi<StoreState>['getState'] | ||||||
| ) => T; | ) => T; | ||||||
|  |  | ||||||
|  | export const createPartializedState = (state: StoreState) => ({ | ||||||
|  |   chats: state.chats, | ||||||
|  |   currentChatIndex: state.currentChatIndex, | ||||||
|  |   apiKey: state.apiKey, | ||||||
|  |   apiEndpoint: state.apiEndpoint, | ||||||
|  |   theme: state.theme, | ||||||
|  |   autoTitle: state.autoTitle, | ||||||
|  |   prompts: state.prompts, | ||||||
|  |   defaultChatConfig: state.defaultChatConfig, | ||||||
|  |   defaultSystemMessage: state.defaultSystemMessage, | ||||||
|  |   hideMenuOptions: state.hideMenuOptions, | ||||||
|  |   firstVisit: state.firstVisit, | ||||||
|  |   hideSideMenu: state.hideSideMenu, | ||||||
|  |   folders: state.folders, | ||||||
|  |   enterToSubmit: state.enterToSubmit, | ||||||
|  | }); | ||||||
|  |  | ||||||
| const useStore = create<StoreState>()( | const useStore = create<StoreState>()( | ||||||
|   persist( |   persist( | ||||||
|     (set, get) => ({ |     (set, get) => ({ | ||||||
| @@ -45,25 +64,11 @@ const useStore = create<StoreState>()( | |||||||
|       ...createAuthSlice(set, get), |       ...createAuthSlice(set, get), | ||||||
|       ...createConfigSlice(set, get), |       ...createConfigSlice(set, get), | ||||||
|       ...createPromptSlice(set, get), |       ...createPromptSlice(set, get), | ||||||
|  |       ...createToastSlice(set, get), | ||||||
|     }), |     }), | ||||||
|     { |     { | ||||||
|       name: 'free-chat-gpt', |       name: 'free-chat-gpt', | ||||||
|       partialize: (state) => ({ |       partialize: (state) => createPartializedState(state), | ||||||
|         chats: state.chats, |  | ||||||
|         currentChatIndex: state.currentChatIndex, |  | ||||||
|         apiKey: state.apiKey, |  | ||||||
|         apiEndpoint: state.apiEndpoint, |  | ||||||
|         theme: state.theme, |  | ||||||
|         autoTitle: state.autoTitle, |  | ||||||
|         prompts: state.prompts, |  | ||||||
|         defaultChatConfig: state.defaultChatConfig, |  | ||||||
|         defaultSystemMessage: state.defaultSystemMessage, |  | ||||||
|         hideMenuOptions: state.hideMenuOptions, |  | ||||||
|         firstVisit: state.firstVisit, |  | ||||||
|         hideSideMenu: state.hideSideMenu, |  | ||||||
|         folders: state.folders, |  | ||||||
|         enterToSubmit: state.enterToSubmit, |  | ||||||
|       }), |  | ||||||
|       version: 8, |       version: 8, | ||||||
|       migrate: (persistedState, version) => { |       migrate: (persistedState, version) => { | ||||||
|         switch (version) { |         switch (version) { | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								src/store/toast-slice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/store/toast-slice.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | import { ToastStatus } from '@components/Toast/Toast'; | ||||||
|  | import { StoreSlice } from './store'; | ||||||
|  |  | ||||||
|  | export interface ToastSlice { | ||||||
|  |   toastShow: boolean; | ||||||
|  |   toastMessage: string; | ||||||
|  |   toastStatus: ToastStatus; | ||||||
|  |   setToastShow: (toastShow: boolean) => void; | ||||||
|  |   setToastMessage: (toastMessage: string) => void; | ||||||
|  |   setToastStatus: (toastStatus: ToastStatus) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const createToastSlice: StoreSlice<ToastSlice> = (set, get) => ({ | ||||||
|  |   toastShow: false, | ||||||
|  |   toastMessage: '', | ||||||
|  |   toastStatus: 'success', | ||||||
|  |   setToastShow: (toastShow: boolean) => { | ||||||
|  |     set((prev) => ({ ...prev, toastShow })); | ||||||
|  |   }, | ||||||
|  |   setToastMessage: (toastMessage: string) => { | ||||||
|  |     set((prev: ToastSlice) => ({ ...prev, toastMessage })); | ||||||
|  |   }, | ||||||
|  |   setToastStatus: (toastStatus: ToastStatus) => { | ||||||
|  |     set((prev: ToastSlice) => ({ ...prev, toastStatus })); | ||||||
|  |   }, | ||||||
|  | }); | ||||||
							
								
								
									
										27
									
								
								src/types/google-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/types/google-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | export interface GoogleFileResource { | ||||||
|  |   kind: string; | ||||||
|  |   id: string; | ||||||
|  |   name: string; | ||||||
|  |   mimeType: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface GoogleTokenInfo { | ||||||
|  |   azp: string; | ||||||
|  |   aud: string; | ||||||
|  |   sub: string; | ||||||
|  |   scope: string; | ||||||
|  |   exp: string; | ||||||
|  |   expires_in: string; | ||||||
|  |   email: string; | ||||||
|  |   email_verified: string; | ||||||
|  |   access_type: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface GoogleFileList { | ||||||
|  |   nextPageToken?: string; | ||||||
|  |   kind: string; | ||||||
|  |   incompleteSearch: boolean; | ||||||
|  |   files: GoogleFileResource[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type SyncStatus = 'unauthenticated' | 'syncing' | 'synced'; | ||||||
							
								
								
									
										5
									
								
								src/types/persist.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/types/persist.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | import { LocalStorageInterfaceV7oV8 } from './chat'; | ||||||
|  |  | ||||||
|  | interface PersistStorageState extends LocalStorageInterfaceV7oV8 {} | ||||||
|  |  | ||||||
|  | export default PersistStorageState; | ||||||
							
								
								
									
										38
									
								
								src/utils/google-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/utils/google-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | import { listDriveFiles } from '@api/google-api'; | ||||||
|  |  | ||||||
|  | import useStore, { createPartializedState } from '@store/store'; | ||||||
|  | import useCloudAuthStore from '@store/cloud-auth-store'; | ||||||
|  |  | ||||||
|  | export const getFiles = async (googleAccessToken: string) => { | ||||||
|  |   try { | ||||||
|  |     const driveFiles = await listDriveFiles(googleAccessToken); | ||||||
|  |     return driveFiles.files; | ||||||
|  |   } catch (e: unknown) { | ||||||
|  |     useCloudAuthStore.getState().setSyncStatus('unauthenticated'); | ||||||
|  |     useStore.getState().setToastMessage((e as Error).message); | ||||||
|  |     useStore.getState().setToastShow(true); | ||||||
|  |     useStore.getState().setToastStatus('error'); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const getFileID = async ( | ||||||
|  |   googleAccessToken: string | ||||||
|  | ): Promise<string | null> => { | ||||||
|  |   const driveFiles = await listDriveFiles(googleAccessToken); | ||||||
|  |   if (driveFiles.files.length === 0) return null; | ||||||
|  |   return driveFiles.files[0].id; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const stateToFile = () => { | ||||||
|  |   const partializedState = createPartializedState(useStore.getState()); | ||||||
|  |  | ||||||
|  |   const blob = new Blob([JSON.stringify(partializedState)], { | ||||||
|  |     type: 'application/json', | ||||||
|  |   }); | ||||||
|  |   const file = new File([blob], 'better-chatgpt.json', { | ||||||
|  |     type: 'application/json', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   return file; | ||||||
|  | }; | ||||||
| @@ -345,6 +345,11 @@ | |||||||
|     "@nodelib/fs.scandir" "2.1.5" |     "@nodelib/fs.scandir" "2.1.5" | ||||||
|     fastq "^1.6.0" |     fastq "^1.6.0" | ||||||
|  |  | ||||||
|  | "@react-oauth/google@^0.9.0": | ||||||
|  |   version "0.9.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.9.0.tgz#af65ee0c6238a0988056d9e820029f99255f8381" | ||||||
|  |   integrity sha512-iq9I6A4uwZezU/BixqLM6UET6an559ufC4Nh0lEIeIaKC3TJRvcPNWCjjHny56yAhgdT6ivUicLIvEoiSMjnmg== | ||||||
|  |  | ||||||
| "@rollup/plugin-virtual@^3.0.1": | "@rollup/plugin-virtual@^3.0.1": | ||||||
|   version "3.0.1" |   version "3.0.1" | ||||||
|   resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz#cea7e489481cc0ca91516c047f8c53c1cfb1adf6" |   resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz#cea7e489481cc0ca91516c047f8c53c1cfb1adf6" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Jing Hua
					Jing Hua