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