mirror of
https://github.com/NovaOSS/nova-betterchat.git
synced 2024-11-25 15:53:59 +01: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:
parent
02697408ce
commit
3f0ada4a9d
|
@ -3,3 +3,4 @@ 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
|
||||
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();
|
||||
|
||||
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}/`);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
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 { 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() {
|
|||
<Menu />
|
||||
<Chat />
|
||||
<ApiPopup />
|
||||
<Toast />
|
||||
</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;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
const RefreshIcon = () => {
|
||||
const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
stroke='currentColor'
|
||||
|
@ -13,6 +13,7 @@ const RefreshIcon = () => {
|
|||
height='1em'
|
||||
width='1em'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<polyline points='1 4 1 10 7 10'></polyline>
|
||||
<polyline points='23 20 23 14 17 14'></polyline>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
const TickIcon = () => {
|
||||
const TickIcon = (props: React.SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
stroke='currentColor'
|
||||
|
@ -13,6 +13,7 @@ const TickIcon = () => {
|
|||
height='1em'
|
||||
width='1em'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<polyline points='20 6 9 17 4 12'></polyline>
|
||||
</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[];
|
||||
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);
|
||||
|
|
|
@ -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 && <GoogleSync clientId={googleClientId} />}
|
||||
<AboutMenu />
|
||||
<ClearConversation />
|
||||
<ImportExportChat />
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
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 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',
|
||||
});
|
||||
|
||||
|
|
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 { ConfigSlice, createConfigSlice } from './config-slice';
|
||||
import { PromptSlice, createPromptSlice } from './prompt-slice';
|
||||
import { ToastSlice, createToastSlice } from './toast-slice';
|
||||
import {
|
||||
LocalStorageInterfaceV0ToV1,
|
||||
LocalStorageInterfaceV1ToV2,
|
||||
|
@ -30,25 +31,15 @@ export type StoreState = ChatSlice &
|
|||
InputSlice &
|
||||
AuthSlice &
|
||||
ConfigSlice &
|
||||
PromptSlice;
|
||||
PromptSlice &
|
||||
ToastSlice;
|
||||
|
||||
export type StoreSlice<T> = (
|
||||
set: StoreApi<StoreState>['setState'],
|
||||
get: StoreApi<StoreState>['getState']
|
||||
) => T;
|
||||
|
||||
const useStore = create<StoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...createChatSlice(set, get),
|
||||
...createInputSlice(set, get),
|
||||
...createAuthSlice(set, get),
|
||||
...createConfigSlice(set, get),
|
||||
...createPromptSlice(set, get),
|
||||
}),
|
||||
{
|
||||
name: 'free-chat-gpt',
|
||||
partialize: (state) => ({
|
||||
export const createPartializedState = (state: StoreState) => ({
|
||||
chats: state.chats,
|
||||
currentChatIndex: state.currentChatIndex,
|
||||
apiKey: state.apiKey,
|
||||
|
@ -63,7 +54,21 @@ const useStore = create<StoreState>()(
|
|||
hideSideMenu: state.hideSideMenu,
|
||||
folders: state.folders,
|
||||
enterToSubmit: state.enterToSubmit,
|
||||
});
|
||||
|
||||
const useStore = create<StoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...createChatSlice(set, get),
|
||||
...createInputSlice(set, get),
|
||||
...createAuthSlice(set, get),
|
||||
...createConfigSlice(set, get),
|
||||
...createPromptSlice(set, get),
|
||||
...createToastSlice(set, get),
|
||||
}),
|
||||
{
|
||||
name: 'free-chat-gpt',
|
||||
partialize: (state) => createPartializedState(state),
|
||||
version: 8,
|
||||
migrate: (persistedState, 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"
|
||||
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"
|
||||
|
|
Loading…
Reference in a new issue