From 3f0ada4a9dcab4a560d508e277baeafe57d5c7e7 Mon Sep 17 00:00:00 2001 From: Jing Hua <59118459+ztjhz@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:29:13 +0800 Subject: [PATCH] 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 --- .env.example | 3 +- electron/index.cjs | 74 +++- package.json | 1 + public/locales/da/drive.json | 16 + public/locales/en-US/drive.json | 16 + public/locales/en/drive.json | 16 + public/locales/es/drive.json | 16 + public/locales/fr/drive.json | 16 + public/locales/it/drive.json | 16 + public/locales/ja/drive.json | 16 + public/locales/ms/drive.json | 16 + public/locales/nb/drive.json | 16 + public/locales/sv/drive.json | 16 + public/locales/zh-CN/drive.json | 16 + public/locales/zh-HK/drive.json | 16 + public/locales/zh-TW/drive.json | 16 + src/App.tsx | 2 + src/api/google-api.ts | 191 +++++++++ src/api/helper.ts | 22 + src/assets/icons/GoogleIcon.tsx | 17 + src/assets/icons/RefreshIcon.tsx | 3 +- src/assets/icons/TickIcon.tsx | 3 +- src/components/GoogleSync/GoogleSync.tsx | 379 ++++++++++++++++++ .../GoogleSync/GoogleSyncButton.tsx | 67 ++++ src/components/GoogleSync/index.ts | 1 + src/components/Menu/ChatFolder.tsx | 6 +- .../Menu/MenuOptions/MenuOptions.tsx | 4 + src/components/SettingsMenu/SettingsMenu.tsx | 2 + src/components/Toast/Toast.tsx | 134 +++++++ src/components/Toast/index.ts | 1 + src/i18n.ts | 28 +- src/store/cloud-auth-slice.ts | 50 +++ src/store/cloud-auth-store.ts | 28 ++ src/store/storage/GoogleCloudStorage.ts | 72 ++++ src/store/store.ts | 39 +- src/store/toast-slice.ts | 26 ++ src/types/google-api.ts | 27 ++ src/types/persist.ts | 5 + src/utils/google-api.ts | 38 ++ yarn.lock | 5 + 40 files changed, 1407 insertions(+), 29 deletions(-) create mode 100644 public/locales/da/drive.json create mode 100644 public/locales/en-US/drive.json create mode 100644 public/locales/en/drive.json create mode 100644 public/locales/es/drive.json create mode 100644 public/locales/fr/drive.json create mode 100644 public/locales/it/drive.json create mode 100644 public/locales/ja/drive.json create mode 100644 public/locales/ms/drive.json create mode 100644 public/locales/nb/drive.json create mode 100644 public/locales/sv/drive.json create mode 100644 public/locales/zh-CN/drive.json create mode 100644 public/locales/zh-HK/drive.json create mode 100644 public/locales/zh-TW/drive.json create mode 100644 src/api/google-api.ts create mode 100644 src/assets/icons/GoogleIcon.tsx create mode 100644 src/components/GoogleSync/GoogleSync.tsx create mode 100644 src/components/GoogleSync/GoogleSyncButton.tsx create mode 100644 src/components/GoogleSync/index.ts create mode 100644 src/components/Toast/Toast.tsx create mode 100644 src/components/Toast/index.ts create mode 100644 src/store/cloud-auth-slice.ts create mode 100644 src/store/cloud-auth-store.ts create mode 100644 src/store/storage/GoogleCloudStorage.ts create mode 100644 src/store/toast-slice.ts create mode 100644 src/types/google-api.ts create mode 100644 src/types/persist.ts create mode 100644 src/utils/google-api.ts diff --git a/.env.example b/.env.example index 2a3d554..4953e2b 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ VITE_CUSTOM_API_ENDPOINT= VITE_DEFAULT_API_ENDPOINT= VITE_OPENAI_API_KEY= -VITE_DEFAULT_SYSTEM_MESSAGE= # Remove this line if you want to use the default system message of Better ChatGPT \ No newline at end of file +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 \ No newline at end of file diff --git a/electron/index.cjs b/electron/index.cjs index aa01c31..26d8454 100644 --- a/electron/index.cjs +++ b/electron/index.cjs @@ -6,6 +6,8 @@ const { autoUpdater } = require('electron-updater'); if (require('electron-squirrel-startup')) app.quit(); +const PORT = isDev ? '5173' : '51735'; + function createWindow() { let iconPath = ''; if (isDev) { @@ -30,11 +32,9 @@ function createWindow() { win.maximize(); win.show(); - win.loadURL( - isDev - ? 'http://localhost:5173' - : `file://${path.join(__dirname, '../dist/index.html')}` - ); + isDev || createServer(); + + win.loadURL(`http://localhost:${PORT}`); if (isDev) { win.webContents.openDevTools({ mode: 'detach' }); @@ -81,3 +81,67 @@ app.on('activate', () => { 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}/`); + }); +}; diff --git a/package.json b/package.json index c6a4c2c..deb4365 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@dqbd/tiktoken": "^1.0.2", + "@react-oauth/google": "^0.9.0", "electron-is-dev": "^2.0.0", "electron-squirrel-startup": "^1.0.0", "electron-updater": "^5.3.0", diff --git a/public/locales/da/drive.json b/public/locales/da/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/da/drive.json @@ -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" + } +} diff --git a/public/locales/en-US/drive.json b/public/locales/en-US/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/en-US/drive.json @@ -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" + } +} diff --git a/public/locales/en/drive.json b/public/locales/en/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/en/drive.json @@ -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" + } +} diff --git a/public/locales/es/drive.json b/public/locales/es/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/es/drive.json @@ -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" + } +} diff --git a/public/locales/fr/drive.json b/public/locales/fr/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/fr/drive.json @@ -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" + } +} diff --git a/public/locales/it/drive.json b/public/locales/it/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/it/drive.json @@ -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" + } +} diff --git a/public/locales/ja/drive.json b/public/locales/ja/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/ja/drive.json @@ -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" + } +} diff --git a/public/locales/ms/drive.json b/public/locales/ms/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/ms/drive.json @@ -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" + } +} diff --git a/public/locales/nb/drive.json b/public/locales/nb/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/nb/drive.json @@ -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" + } +} diff --git a/public/locales/sv/drive.json b/public/locales/sv/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/sv/drive.json @@ -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" + } +} diff --git a/public/locales/zh-CN/drive.json b/public/locales/zh-CN/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/zh-CN/drive.json @@ -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" + } +} diff --git a/public/locales/zh-HK/drive.json b/public/locales/zh-HK/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/zh-HK/drive.json @@ -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" + } +} diff --git a/public/locales/zh-TW/drive.json b/public/locales/zh-TW/drive.json new file mode 100644 index 0000000..b03db6b --- /dev/null +++ b/public/locales/zh-TW/drive.json @@ -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" + } +} diff --git a/src/App.tsx b/src/App.tsx index 565dd5d..7f9fb8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; import { ChatInterface } from '@type/chat'; import { Theme } from '@type/theme'; import ApiPopup from '@components/ApiPopup'; +import Toast from '@components/Toast'; function App() { const initialiseNewChat = useInitialiseNewChat(); @@ -78,6 +79,7 @@ function App() { + ); } diff --git a/src/api/google-api.ts b/src/api/google-api.ts new file mode 100644 index 0000000..8c2700a --- /dev/null +++ b/src/api/google-api.ts @@ -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 => { + 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 ( + fileId: string, + accessToken: string +): Promise> => { + 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 = await response.json(); + return result; +}; + +export const getDriveFileTyped = async ( + fileId: string, + accessToken: string +): Promise> => { + return await getDriveFile(fileId, accessToken); +}; + +export const listDriveFiles = async ( + accessToken: string +): Promise => { + 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 => { + 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 +); diff --git a/src/api/helper.ts b/src/api/helper.ts index f46b9c9..80782d0 100644 --- a/src/api/helper.ts +++ b/src/api/helper.ts @@ -21,3 +21,25 @@ export const parseEventSource = ( }); 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, + }); +}; diff --git a/src/assets/icons/GoogleIcon.tsx b/src/assets/icons/GoogleIcon.tsx new file mode 100644 index 0000000..bf38185 --- /dev/null +++ b/src/assets/icons/GoogleIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const GoogleIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; + +export default GoogleIcon; diff --git a/src/assets/icons/RefreshIcon.tsx b/src/assets/icons/RefreshIcon.tsx index de44047..4418d7d 100644 --- a/src/assets/icons/RefreshIcon.tsx +++ b/src/assets/icons/RefreshIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; -const RefreshIcon = () => { +const RefreshIcon = (props: React.SVGProps) => { return ( { height='1em' width='1em' xmlns='http://www.w3.org/2000/svg' + {...props} > diff --git a/src/assets/icons/TickIcon.tsx b/src/assets/icons/TickIcon.tsx index 6d5d58d..94ffb5f 100644 --- a/src/assets/icons/TickIcon.tsx +++ b/src/assets/icons/TickIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; -const TickIcon = () => { +const TickIcon = (props: React.SVGProps) => { return ( { height='1em' width='1em' xmlns='http://www.w3.org/2000/svg' + {...props} > diff --git a/src/components/GoogleSync/GoogleSync.tsx b/src/components/GoogleSync/GoogleSync.tsx new file mode 100644 index 0000000..3cdca8e --- /dev/null +++ b/src/components/GoogleSync/GoogleSync.tsx @@ -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(cloudSync); + const [files, setFiles] = useState([]); + + 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 ( + +
{ + setIsModalOpen(true); + }} + > + {t('name')} + {cloudSync && } +
+ {isModalOpen && ( + + )} + + ); +}; + +const GooglePopup = ({ + setIsModalOpen, + files, + setFiles, +}: { + setIsModalOpen: React.Dispatch>; + files: GoogleFileResource[]; + setFiles: React.Dispatch>; +}) => { + 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( + 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 ( + +
+

{t('tagline')}

+ { + setIsModalOpen(false); + window.setTimeout(() => { + setIsModalOpen(true); + }, 3540000); // timeout - 3540000ms = 59 min (access token last 60 min) + }} + /> +

+ {t('notice')} +

+ {cloudSync && syncStatus !== 'unauthenticated' && ( +
+ {files.map((file) => ( + + ))} + {syncStatus !== 'syncing' && ( +
+
{ + setFileId(_fileId); + await useStore.persist.rehydrate(); + setToastStatus('success'); + setToastMessage(t('toast.sync')); + setToastShow(true); + setIsModalOpen(false); + }} + > + {t('button.confirm')} +
+
+ {t('button.create')} +
+
+ )} +
+ {syncStatus === 'syncing' && } +
+
+ )} +

{t('privacy')}

+
+
+ ); +}; + +const FileSelector = ({ + name, + id, + _fileId, + _setFileId, + setFiles, +}: { + name: string; + id: string; + _fileId: string; + _setFileId: React.Dispatch>; + setFiles: React.Dispatch>; +}) => { + 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(false); + const [isDeleting, setIsDeleting] = useState(false); + const [_name, _setName] = useState(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 ( + + ); +}; + +const SyncIcon = ({ status }: { status: SyncStatus }) => { + const statusToIcon = { + unauthenticated: ( +
+ ! +
+ ), + syncing: ( +
+ +
+ ), + synced: ( +
+ +
+ ), + }; + return statusToIcon[status] || null; +}; + +export default GoogleSync; diff --git a/src/components/GoogleSync/GoogleSyncButton.tsx b/src/components/GoogleSync/GoogleSyncButton.tsx new file mode 100644 index 0000000..69d21ec --- /dev/null +++ b/src/components/GoogleSync/GoogleSyncButton.tsx @@ -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 ( +
+ + {cloudSync && ( + + )} +
+ ); +}; + +export default GoogleSyncButton; diff --git a/src/components/GoogleSync/index.ts b/src/components/GoogleSync/index.ts new file mode 100644 index 0000000..71db429 --- /dev/null +++ b/src/components/GoogleSync/index.ts @@ -0,0 +1 @@ +export { default } from './GoogleSync'; diff --git a/src/components/Menu/ChatFolder.tsx b/src/components/Menu/ChatFolder.tsx index ad93d59..9e1f6d2 100644 --- a/src/components/Menu/ChatFolder.tsx +++ b/src/components/Menu/ChatFolder.tsx @@ -27,9 +27,9 @@ const ChatFolder = ({ folderChats: ChatHistoryInterface[]; folderId: string; }) => { - const folderName = useStore((state) => state.folders[folderId].name); - const isExpanded = useStore((state) => state.folders[folderId].expanded); - const color = useStore((state) => state.folders[folderId].color); + const folderName = useStore((state) => state.folders[folderId]?.name); + const isExpanded = useStore((state) => state.folders[folderId]?.expanded); + const color = useStore((state) => state.folders[folderId]?.color); const setChats = useStore((state) => state.setChats); const setFolders = useStore((state) => state.setFolders); diff --git a/src/components/Menu/MenuOptions/MenuOptions.tsx b/src/components/Menu/MenuOptions/MenuOptions.tsx index aa76092..d0114c1 100644 --- a/src/components/Menu/MenuOptions/MenuOptions.tsx +++ b/src/components/Menu/MenuOptions/MenuOptions.tsx @@ -8,6 +8,9 @@ import AboutMenu from '@components/AboutMenu'; import ImportExportChat from '@components/ImportExportChat'; import SettingsMenu from '@components/SettingsMenu'; import CollapseOptions from './CollapseOptions'; +import GoogleSync from '@components/GoogleSync'; + +const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined; const MenuOptions = () => { const hideMenuOptions = useStore((state) => state.hideMenuOptions); @@ -19,6 +22,7 @@ const MenuOptions = () => { hideMenuOptions ? 'max-h-0' : 'max-h-full' } overflow-hidden transition-all`} > + {googleClientId && } diff --git a/src/components/SettingsMenu/SettingsMenu.tsx b/src/components/SettingsMenu/SettingsMenu.tsx index 6994362..51f336a 100644 --- a/src/components/SettingsMenu/SettingsMenu.tsx +++ b/src/components/SettingsMenu/SettingsMenu.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; +import useCloudAuthStore from '@store/cloud-auth-store'; import PopupModal from '@components/PopupModal'; import SettingIcon from '@icon/SettingIcon'; @@ -11,6 +12,7 @@ import PromptLibraryMenu from '@components/PromptLibraryMenu'; import ChatConfigMenu from '@components/ChatConfigMenu'; import EnterToSubmitToggle from './EnterToSubmitToggle'; + const SettingsMenu = () => { const { t } = useTranslation(); diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx new file mode 100644 index 0000000..32d2f57 --- /dev/null +++ b/src/components/Toast/Toast.tsx @@ -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(); + + useEffect(() => { + if (toastShow) { + window.clearTimeout(timeoutID); + + const newTimeoutID = window.setTimeout(() => { + setToastShow(false); + }, 5000); + + setTimeoutID(newTimeoutID); + } + }, [toastShow, status, message]); + + return toastShow ? ( +
+ +
{message}
+ +
+ ) : ( + <> + ); +}; + +const StatusIcon = ({ status }: { status: ToastStatus }) => { + const statusToIcon = { + success: , + error: , + warning: , + }; + return statusToIcon[status] || null; +}; + +const CloseIcon = () => ( + <> + Close + + +); + +const CheckIcon = () => ( +
+ + Check icon +
+); + +const ErrorIcon = () => ( +
+ + Error icon +
+); + +const WarningIcon = () => ( +
+ + Warning icon +
+); + +export default Toast; diff --git a/src/components/Toast/index.ts b/src/components/Toast/index.ts new file mode 100644 index 0000000..55a859a --- /dev/null +++ b/src/components/Toast/index.ts @@ -0,0 +1 @@ +export { default } from './Toast'; diff --git a/src/i18n.ts b/src/i18n.ts index 9f15fc0..94a03a3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -4,6 +4,32 @@ import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; 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 .use(Backend) .use(LanguageDetector) @@ -15,7 +41,7 @@ i18n fallbackLng: { default: ['en'], }, - ns: ['main', 'api', 'about', 'model'], + ns: namespace, defaultNS: 'main', }); diff --git a/src/store/cloud-auth-slice.ts b/src/store/cloud-auth-slice.ts new file mode 100644 index 0000000..08f452e --- /dev/null +++ b/src/store/cloud-auth-slice.ts @@ -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 = (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, + })); + }, +}); diff --git a/src/store/cloud-auth-store.ts b/src/store/cloud-auth-store.ts new file mode 100644 index 0000000..f5282c0 --- /dev/null +++ b/src/store/cloud-auth-store.ts @@ -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 = ( + set: StoreApi['setState'], + get: StoreApi['getState'] +) => T; + +const useCloudAuthStore = create()( + persist( + (set, get) => ({ + ...createCloudAuthSlice(set, get), + }), + { + name: 'cloud', + partialize: (state) => ({ + cloudSync: state.cloudSync, + fileId: state.fileId, + }), + version: 1, + } + ) +); + +export default useCloudAuthStore; diff --git a/src/store/storage/GoogleCloudStorage.ts b/src/store/storage/GoogleCloudStorage.ts new file mode 100644 index 0000000..59078d6 --- /dev/null +++ b/src/store/storage/GoogleCloudStorage.ts @@ -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 = (): PersistStorage | 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 = { + 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 = 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 => { + 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 => { + const accessToken = useCloudAuthStore.getState().googleAccessToken; + const fileId = useCloudAuthStore.getState().fileId; + if (!accessToken || !fileId) return; + + await deleteDriveFile(accessToken, fileId); + }, + }; + return persistStorage; +}; + +export default createGoogleCloudStorage; diff --git a/src/store/store.ts b/src/store/store.ts index c5ab6ab..03392bc 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -5,6 +5,7 @@ import { InputSlice, createInputSlice } from './input-slice'; import { AuthSlice, createAuthSlice } from './auth-slice'; import { ConfigSlice, createConfigSlice } from './config-slice'; import { PromptSlice, createPromptSlice } from './prompt-slice'; +import { ToastSlice, createToastSlice } from './toast-slice'; import { LocalStorageInterfaceV0ToV1, LocalStorageInterfaceV1ToV2, @@ -30,13 +31,31 @@ export type StoreState = ChatSlice & InputSlice & AuthSlice & ConfigSlice & - PromptSlice; + PromptSlice & + ToastSlice; export type StoreSlice = ( set: StoreApi['setState'], get: StoreApi['getState'] ) => 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()( persist( (set, get) => ({ @@ -45,25 +64,11 @@ const useStore = create()( ...createAuthSlice(set, get), ...createConfigSlice(set, get), ...createPromptSlice(set, get), + ...createToastSlice(set, get), }), { name: 'free-chat-gpt', - partialize: (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, - }), + partialize: (state) => createPartializedState(state), version: 8, migrate: (persistedState, version) => { switch (version) { diff --git a/src/store/toast-slice.ts b/src/store/toast-slice.ts new file mode 100644 index 0000000..e3a9d80 --- /dev/null +++ b/src/store/toast-slice.ts @@ -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 = (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 })); + }, +}); diff --git a/src/types/google-api.ts b/src/types/google-api.ts new file mode 100644 index 0000000..bd197d7 --- /dev/null +++ b/src/types/google-api.ts @@ -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'; diff --git a/src/types/persist.ts b/src/types/persist.ts new file mode 100644 index 0000000..4285b6f --- /dev/null +++ b/src/types/persist.ts @@ -0,0 +1,5 @@ +import { LocalStorageInterfaceV7oV8 } from './chat'; + +interface PersistStorageState extends LocalStorageInterfaceV7oV8 {} + +export default PersistStorageState; diff --git a/src/utils/google-api.ts b/src/utils/google-api.ts new file mode 100644 index 0000000..fd2bd8a --- /dev/null +++ b/src/utils/google-api.ts @@ -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 => { + 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; +}; diff --git a/yarn.lock b/yarn.lock index fb1842f..717c965 100644 --- a/yarn.lock +++ b/yarn.lock @@ -345,6 +345,11 @@ "@nodelib/fs.scandir" "2.1.5" 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": version "3.0.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz#cea7e489481cc0ca91516c047f8c53c1cfb1adf6"