mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/googleDrive (#4592)
Google drive oss. Shouldn't have any effect on pr deployment. Mainly the removal of the old integration via backend. I have added the picker service and lazy loading of the required google dependency scripts when the necessary environment variables have been implemented. --------- Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Connor Yoh
James Brunton
parent
3090a85726
commit
2158ee4db6
@@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
import DragOverlay from './fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -84,6 +86,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload Google Drive scripts if configured
|
||||
useEffect(() => {
|
||||
if (isGoogleDriveConfigured()) {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
|
||||
@@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
onGoogleDriveSelect(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick files from Google Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={buttonProps.variant('drive')}
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,6 @@ const Overview: React.FC = () => {
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface FileManagerContextValue {
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
onGoogleDriveSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: StirlingFileStub[];
|
||||
@@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
const handleGoogleDriveSelect = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Process Google Drive files same as local files
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to process Google Drive files:', error);
|
||||
}
|
||||
}
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Load the full file from storage
|
||||
@@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
onGoogleDriveSelect: handleGoogleDriveSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
@@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
handleGoogleDriveSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface AppConfig {
|
||||
runningProOrHigher?: boolean;
|
||||
runningEE?: boolean;
|
||||
license?: string;
|
||||
GoogleDriveEnabled?: boolean;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* React hook for Google Drive file picker
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
getGoogleDrivePickerService,
|
||||
isGoogleDriveConfigured,
|
||||
getGoogleDriveConfig,
|
||||
} from '../services/googleDrivePickerService';
|
||||
|
||||
interface UseGoogleDrivePickerOptions {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string;
|
||||
}
|
||||
|
||||
interface UseGoogleDrivePickerReturn {
|
||||
isEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
openPicker: (options?: UseGoogleDrivePickerOptions) => Promise<File[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use Google Drive file picker
|
||||
*/
|
||||
export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Check if Google Drive is configured on mount
|
||||
useEffect(() => {
|
||||
const configured = isGoogleDriveConfigured();
|
||||
setIsEnabled(configured);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize the Google Drive service (lazy initialization)
|
||||
*/
|
||||
const initializeService = useCallback(async () => {
|
||||
if (isInitialized) return;
|
||||
|
||||
const config = getGoogleDriveConfig();
|
||||
if (!config) {
|
||||
throw new Error('Google Drive is not configured');
|
||||
}
|
||||
|
||||
const service = getGoogleDrivePickerService();
|
||||
await service.initialize(config);
|
||||
setIsInitialized(true);
|
||||
}, [isInitialized]);
|
||||
|
||||
/**
|
||||
* Open the Google Drive picker
|
||||
*/
|
||||
const openPicker = useCallback(
|
||||
async (options: UseGoogleDrivePickerOptions = {}): Promise<File[]> => {
|
||||
if (!isEnabled) {
|
||||
setError('Google Drive is not configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Initialize service if needed
|
||||
await initializeService();
|
||||
|
||||
// Open picker
|
||||
const service = getGoogleDrivePickerService();
|
||||
const files = await service.openPicker({
|
||||
multiple: options.multiple ?? true,
|
||||
mimeTypes: options.mimeTypes,
|
||||
});
|
||||
|
||||
return files;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker';
|
||||
setError(errorMessage);
|
||||
console.error('Google Drive picker error:', err);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isEnabled, initializeService]
|
||||
);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isLoading,
|
||||
error,
|
||||
openPicker,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Drive Picker Service
|
||||
* Handles Google Drive file picker integration
|
||||
*/
|
||||
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
|
||||
const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
|
||||
|
||||
interface GoogleDriveConfig {
|
||||
clientId: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
interface PickerOptions {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string | null;
|
||||
}
|
||||
|
||||
// Expandable mime types for Google Picker
|
||||
const expandableMimeTypes: Record<string, string[]> = {
|
||||
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert file input accept attribute to Google Picker mime types
|
||||
*/
|
||||
function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
|
||||
if (!accept || accept === '' || accept.includes('*/*')) {
|
||||
// Setting null will accept all supported mimetypes
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeTypes: string[] = [];
|
||||
accept.split(',').forEach((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
if (!(trimmedPart in expandableMimeTypes)) {
|
||||
mimeTypes.push(trimmedPart);
|
||||
return;
|
||||
}
|
||||
|
||||
expandableMimeTypes[trimmedPart].forEach((mimeType) => {
|
||||
mimeTypes.push(mimeType);
|
||||
});
|
||||
});
|
||||
|
||||
return mimeTypes.join(',').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
class GoogleDrivePickerService {
|
||||
private config: GoogleDriveConfig | null = null;
|
||||
private tokenClient: any = null;
|
||||
private accessToken: string | null = null;
|
||||
private gapiLoaded = false;
|
||||
private gisLoaded = false;
|
||||
|
||||
constructor() {
|
||||
this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service with credentials
|
||||
*/
|
||||
async initialize(config: GoogleDriveConfig): Promise<void> {
|
||||
this.config = config;
|
||||
|
||||
// Load Google APIs
|
||||
await Promise.all([
|
||||
this.loadGapi(),
|
||||
this.loadGis(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google API client
|
||||
*/
|
||||
private async loadGapi(): Promise<void> {
|
||||
if (this.gapiLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
window.gapi.load('client:picker', async () => {
|
||||
await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
|
||||
this.gapiLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Identity Services
|
||||
*/
|
||||
private async loadGis(): Promise<void> {
|
||||
if (this.gisLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive config not initialized');
|
||||
}
|
||||
|
||||
this.tokenClient = window.google.accounts.oauth2.initTokenClient({
|
||||
client_id: this.config.clientId,
|
||||
scope: SCOPES,
|
||||
callback: () => {}, // Will be overridden during picker creation
|
||||
});
|
||||
|
||||
this.gisLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Google Drive picker
|
||||
*/
|
||||
async openPicker(options: PickerOptions = {}): Promise<File[]> {
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive service not initialized');
|
||||
}
|
||||
|
||||
// Request access token
|
||||
await this.requestAccessToken();
|
||||
|
||||
// Create and show picker
|
||||
return this.createPicker(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request access token from Google
|
||||
*/
|
||||
private requestAccessToken(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.tokenClient) {
|
||||
reject(new Error('Token client not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.tokenClient.callback = (response: any) => {
|
||||
if (response.error !== undefined) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if(response.access_token == null){
|
||||
reject(new Error("No acces token in response"));
|
||||
}
|
||||
|
||||
this.accessToken = response.access_token;
|
||||
sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? "");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.tokenClient.requestAccessToken({
|
||||
prompt: this.accessToken === null ? 'consent' : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and display the Google Picker
|
||||
*/
|
||||
private createPicker(options: PickerOptions): Promise<File[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.config || !this.accessToken) {
|
||||
reject(new Error('Not initialized or no access token'));
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined);
|
||||
|
||||
const view1 = new window.google.picker.DocsView().setIncludeFolders(true);
|
||||
if (mimeTypes !== null) {
|
||||
view1.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const view2 = new window.google.picker.DocsView()
|
||||
.setIncludeFolders(true)
|
||||
.setEnableDrives(true);
|
||||
if (mimeTypes !== null) {
|
||||
view2.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const builder = new window.google.picker.PickerBuilder()
|
||||
.setDeveloperKey(this.config.apiKey)
|
||||
.setAppId(this.config.appId)
|
||||
.setOAuthToken(this.accessToken)
|
||||
.addView(view1)
|
||||
.addView(view2)
|
||||
.setCallback((data: any) => this.pickerCallback(data, resolve, reject));
|
||||
|
||||
if (options.multiple) {
|
||||
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
|
||||
}
|
||||
|
||||
const picker = builder.build();
|
||||
picker.setVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle picker selection callback
|
||||
*/
|
||||
private async pickerCallback(
|
||||
data: any,
|
||||
resolve: (files: File[]) => void,
|
||||
reject: (error: Error) => void
|
||||
): Promise<void> {
|
||||
if (data.action === window.google.picker.Action.PICKED) {
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
|
||||
const fileId = pickedFile[window.google.picker.Document.ID];
|
||||
const res = await window.gapi.client.drive.files.get({
|
||||
fileId: fileId,
|
||||
alt: 'media',
|
||||
});
|
||||
|
||||
// Convert response body to File object
|
||||
const file = new File(
|
||||
[new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
|
||||
pickedFile.name,
|
||||
{
|
||||
type: pickedFile.mimeType,
|
||||
lastModified: pickedFile.lastModified,
|
||||
}
|
||||
);
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
resolve(files);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error('Failed to download files'));
|
||||
}
|
||||
} else if (data.action === window.google.picker.Action.CANCEL) {
|
||||
resolve([]); // User cancelled, return empty array
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out and revoke access token
|
||||
*/
|
||||
signOut(): void {
|
||||
if (this.accessToken) {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_ID);
|
||||
window.google?.accounts.oauth2.revoke(this.accessToken, () => {});
|
||||
this.accessToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let serviceInstance: GoogleDrivePickerService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the Google Drive picker service instance
|
||||
*/
|
||||
export function getGoogleDrivePickerService(): GoogleDrivePickerService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new GoogleDrivePickerService();
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Drive credentials are configured
|
||||
*/
|
||||
export function isGoogleDriveConfigured(): boolean {
|
||||
const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
|
||||
const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
|
||||
|
||||
return !!(clientId && apiKey && appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Drive configuration from environment variables
|
||||
*/
|
||||
export function getGoogleDriveConfig(): GoogleDriveConfig | null {
|
||||
if (!isGoogleDriveConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID,
|
||||
apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY,
|
||||
appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Utility for dynamically loading external scripts
|
||||
*/
|
||||
|
||||
interface ScriptLoadOptions {
|
||||
src: string;
|
||||
id?: string;
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const loadedScripts = new Set<string>();
|
||||
|
||||
export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
const scriptId = id || src;
|
||||
if (loadedScripts.has(scriptId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script already exists in DOM
|
||||
const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`);
|
||||
if (existingScript) {
|
||||
loadedScripts.add(scriptId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and append script
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
if (id) script.id = id;
|
||||
script.async = async;
|
||||
script.defer = defer;
|
||||
|
||||
script.onload = () => {
|
||||
loadedScripts.add(scriptId);
|
||||
if (onLoad) onLoad();
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function isScriptLoaded(idOrSrc: string): boolean {
|
||||
return loadedScripts.has(idOrSrc);
|
||||
}
|
||||
Reference in New Issue
Block a user