From 2601279ecb8805a85eccb79fece216e8a01dd055 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Tue, 27 Jan 2026 21:30:23 +0800 Subject: [PATCH] refactor and fix review feedback --- examples/react/package.json | 2 + examples/react/src/App.tsx | 25 +- examples/react/src/CustomDropzone.tsx | 11 - packages/@uppy/companion-client/package.json | 3 + .../src}/googlePicker.ts | 259 +++++++++++++++- packages/@uppy/companion-client/src/index.ts | 19 ++ .../companion-client/tsconfig.build.json | 3 +- packages/@uppy/companion-client/tsconfig.json | 3 +- packages/@uppy/components/package.json | 3 + packages/@uppy/components/src/googlePicker.ts | 53 ++++ .../components/src/hooks/googlePicker.ts | 246 --------------- packages/@uppy/components/src/index.ts | 12 +- packages/@uppy/components/tsconfig.build.json | 6 + packages/@uppy/components/tsconfig.json | 6 + .../src/GoogleDrivePicker.tsx | 51 ++-- .../src/GooglePhotosPicker.tsx | 51 ++-- packages/@uppy/provider-views/package.json | 3 - .../src/GooglePicker/GooglePickerView.tsx | 286 ++++++------------ packages/@uppy/provider-views/src/index.ts | 22 -- .../@uppy/provider-views/tsconfig.build.json | 3 +- packages/@uppy/provider-views/tsconfig.json | 3 +- packages/@uppy/react/src/useGooglePicker.ts | 61 ++-- .../svelte/src/lib/useGooglePicker.svelte.ts | 37 +-- packages/@uppy/vue/src/useGooglePicker.ts | 36 +-- yarn.lock | 11 +- 25 files changed, 585 insertions(+), 630 deletions(-) rename packages/@uppy/{provider-views/src/GooglePicker => companion-client/src}/googlePicker.ts (71%) create mode 100644 packages/@uppy/components/src/googlePicker.ts delete mode 100644 packages/@uppy/components/src/hooks/googlePicker.ts diff --git a/examples/react/package.json b/examples/react/package.json index d9a95ed17..3fc4da4bf 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -11,6 +11,8 @@ "dependencies": { "@tailwindcss/vite": "^4.1.11", "@uppy/core": "workspace:*", + "@uppy/google-drive-picker": "workspace:*", + "@uppy/google-photos-picker": "workspace:*", "@uppy/react": "workspace:*", "@uppy/remote-sources": "workspace:*", "@uppy/screen-capture": "workspace:*", diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 23f7c535f..c05debd00 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,5 +1,6 @@ /** biome-ignore-all lint/nursery/useUniqueElementIds: it's fine */ import Uppy from '@uppy/core' +import GoogleDrivePicker from '@uppy/google-drive-picker' import { Dropzone, FilesGrid, @@ -19,16 +20,36 @@ import Webcam from './Webcam' import './app.css' import '@uppy/react/css/style.css' +import GooglePhotosPicker from '@uppy/google-photos-picker' + +const companionUrl = 'http://localhost:3020' +const googlePickerClientId = '' // see GOOGLE_PICKER_CLIENT_ID in dev Dashboard +const googlePickerApiKey = '' // see GOOGLE_PICKER_API_KEY in dev Dashboard +const googlePickerAppId = '' // see GOOGLE_PICKER_APP_ID in dev Dashboard function App() { const [uppy] = useState(() => - new Uppy() + new Uppy({ + restrictions: { + maxNumberOfFiles: 1, + }, + }) .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/', }) + .use(GoogleDrivePicker, { + clientId: googlePickerClientId, + companionUrl, + apiKey: googlePickerApiKey, + appId: googlePickerAppId, + }) + .use(GooglePhotosPicker, { + clientId: googlePickerClientId, + companionUrl, + }) .use(UppyWebcam) .use(UppyScreenCapture) - .use(UppyRemoteSources, { companionUrl: 'http://localhost:3020' }), + .use(UppyRemoteSources, { companionUrl }), ) const dialogRef = useRef(null) diff --git a/examples/react/src/CustomDropzone.tsx b/examples/react/src/CustomDropzone.tsx index cf526a243..41b82dad5 100644 --- a/examples/react/src/CustomDropzone.tsx +++ b/examples/react/src/CustomDropzone.tsx @@ -11,26 +11,15 @@ export interface CustomDropzoneProps { openModal: (plugin: CustomDropzonePlugin) => void } -const companionUrl = 'http://localhost:3020' -const googlePickerClientId = '' // see GOOGLE_PICKER_CLIENT_ID in dev Dashboard -const googlePickerApiKey = '' // see GOOGLE_PICKER_API_KEY in dev Dashboard -const googlePickerAppId = '' // see GOOGLE_PICKER_APP_ID in dev Dashboard - export function CustomDropzone({ openModal }: CustomDropzoneProps) { const { getRootProps, getInputProps } = useDropzone({ noClick: true }) const { getButtonProps, getInputProps: getFileInputProps } = useFileInput() const googleDrivePicker = useGooglePicker({ - clientId: googlePickerClientId, - companionUrl: companionUrl, - apiKey: googlePickerApiKey, - appId: googlePickerAppId, pickerType: 'drive', }) const googlePhotosPicker = useGooglePicker({ - clientId: googlePickerClientId, - companionUrl: companionUrl, pickerType: 'photos', }) diff --git a/packages/@uppy/companion-client/package.json b/packages/@uppy/companion-client/package.json index 4da8b79e7..6aaae1c05 100644 --- a/packages/@uppy/companion-client/package.json +++ b/packages/@uppy/companion-client/package.json @@ -41,6 +41,9 @@ "p-retry": "^6.1.0" }, "devDependencies": { + "@types/gapi": "^0.0.47", + "@types/google.accounts": "^0.0.14", + "@types/google.picker": "^0.0.42", "jsdom": "^26.1.0", "typescript": "^5.8.3", "vitest": "^3.2.4" diff --git a/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts b/packages/@uppy/companion-client/src/googlePicker.ts similarity index 71% rename from packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts rename to packages/@uppy/companion-client/src/googlePicker.ts index 4f1723cfd..883751d54 100644 --- a/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts +++ b/packages/@uppy/companion-client/src/googlePicker.ts @@ -1,4 +1,14 @@ +import type Uppy from '@uppy/core' + +export type GooglePickerType = 'drive' | 'photos' + +import type { UppyEventMap } from '@uppy/core' import type { Meta } from '@uppy/utils' +import { + type CompanionPluginOptions, + type RequestClientOptions, + tokenStorage, +} from './index.js' // https://developers.google.com/photos/picker/reference/rest/v1/mediaItems // Note that the google api doc is not correct, hence some things are optional here but not in their docs @@ -112,9 +122,7 @@ async function injectScript(src: string) { injectedScripts.add(src) } -export async function ensureScriptsInjected( - pickerType: PickerType, -): Promise { +async function ensureScriptsInjected(pickerType: PickerType): Promise { await Promise.all([ injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services (async () => { @@ -149,7 +157,7 @@ async function isTokenValid( return false } -export async function authorize({ +async function authorize({ pickerType, clientId, accessToken, @@ -190,7 +198,7 @@ export async function authorize({ return response.access_token } -export async function logout(accessToken: string): Promise { +async function doLogout(accessToken: string): Promise { await new Promise((resolve) => google.accounts.oauth2.revoke(accessToken, resolve), ) @@ -285,7 +293,7 @@ async function handleDocObjectRecursively({ return items } -export async function showDrivePicker({ +async function showDrivePicker({ token, apiKey, appId, @@ -352,7 +360,7 @@ export async function showDrivePicker({ signal?.addEventListener('abort', () => picker.dispose()) } -export async function showPhotosPicker({ +async function showPhotosPicker({ token, pickingSession, onPickingSessionChange, @@ -486,7 +494,7 @@ async function resolvePickedPhotos({ }) } -export async function pollPickingSession({ +async function pollPickingSession({ getPickingSession, getAccessToken, onPickingSessionClear, @@ -594,3 +602,238 @@ export const mapPickerFile = ( meta: rest.metadata, }) as Meta), // dunno how to type this }) + +export function createGooglePickerStoreAdapter({ + uppy, + getPluginState, + setPluginState, +}: { + uppy: Uppy + getPluginState: () => GooglePickerState + setPluginState: (state: Partial) => void +}) { + return { + getSnapshot: () => getPluginState(), + setState: ( + updater: + | GooglePickerState + | ((prevState: GooglePickerState) => GooglePickerState), + ) => { + const newState = + typeof updater === 'function' ? updater(getPluginState()) : updater + + setPluginState(newState) + }, + subscribe: (listener: (state: GooglePickerState) => void) => { + const onStateUpdate: UppyEventMap['state-update'] = ( + _prev, + _next, + patch, + ): void => { + const screenCapturePatch = (patch?.plugins?.GoogleDrivePicker ?? + patch?.plugins?.GooglePhotosPicker) as GooglePickerState | undefined + + if (screenCapturePatch) listener(screenCapturePatch) + } + + uppy.on('state-update', onStateUpdate) + return () => uppy.off('state-update', onStateUpdate) + }, + } +} + +export type GooglePickerState = { + loading: boolean + accessToken: string | null | undefined +} + +export type GooglePickerStore = ReturnType< + typeof createGooglePickerStoreAdapter +> + +export interface GooglePickerOptions + extends Pick, + Pick { + pickerType: GooglePickerType + clientId: string + requestClientId: string + apiKey?: string + appId?: string + store: GooglePickerStore +} + +export function createGooglePickerController({ + uppy, + store, + storage: persistentStore = tokenStorage, + pickerType, + companionUrl, + requestClientId = pickerType === 'drive' + ? 'GoogleDrivePicker' + : 'GooglePhotosPicker', + clientId, + apiKey, + appId, +}: { + uppy: Uppy +} & GooglePickerOptions) { + const storageKey = `uppy:google-${pickerType}-picker:accessToken` + + let abortController = new AbortController() + let initPromise: Promise | undefined + let pickingSession: PickingSession | undefined + + const handleFilesPicked = async ( + files: PickedItem[], + accessToken: string, + ) => { + uppy.addFiles( + files.map((file) => + mapPickerFile({ requestClientId, accessToken, companionUrl }, file), + ), + ) + } + + function setAccessToken(newAccessToken: string | null) { + store.setState((s) => ({ ...s, accessToken: newAccessToken })) + if (newAccessToken == null) { + return persistentStore.removeItem(storageKey) + } + return persistentStore.setItem(storageKey, newAccessToken) + } + + function setLoading(v: boolean) { + store.setState((s) => ({ ...s, loading: v })) + } + + async function init() { + if (initPromise == null) { + initPromise = (async () => { + abortController = new AbortController() + + setAccessToken(await persistentStore.getItem(storageKey)) + + abortController.signal.throwIfAborted() + + // For google photos we need to continuously poll the current picking session + if (pickerType === 'photos') { + pollPickingSession({ + getPickingSession: () => pickingSession, + getAccessToken: () => store.getSnapshot().accessToken, + onPickingSessionClear: () => { + pickingSession = undefined + }, + signal: abortController.signal, + onFilesPicked: handleFilesPicked, + onError: (err) => uppy.log(err), + }) + } + })() + } + await initPromise + } + + async function showPicker() { + await init() + + let newAccessToken = store.getSnapshot().accessToken + + const doShowPicker = async (token: string) => { + if (pickerType === 'drive') { + if (apiKey == null || appId == null) + throw new TypeError( + 'apiKey and appId are required for Google Drive picker', + ) + await showDrivePicker({ + token, + apiKey, + appId, + onFilesPicked: handleFilesPicked, + signal: abortController.signal, + onLoadingChange: (isLoading: boolean) => setLoading(isLoading), + onError: (err: unknown) => { + uppy.log(err) + uppy.info(uppy.i18n('failedToAddFiles'), 'error') + }, + }) + } else { + // photos + const onPickingSessionChange = (newPickingSession: PickingSession) => { + pickingSession = newPickingSession + } + await showPhotosPicker({ + token, + pickingSession, + onPickingSessionChange, + signal: abortController.signal, + }) + } + } + + setLoading(true) + try { + try { + await ensureScriptsInjected(pickerType) + + if (newAccessToken == null) { + newAccessToken = await authorize({ clientId, pickerType }) + } + if (newAccessToken == null) throw new Error() + + await doShowPicker(newAccessToken) + setAccessToken(newAccessToken) + } catch (err) { + if (err instanceof InvalidTokenError) { + uppy.log('Token is invalid or expired, reauthenticating') + newAccessToken = await authorize({ + pickerType, + accessToken: newAccessToken, + clientId, + }) + // now try again: + await doShowPicker(newAccessToken) + setAccessToken(newAccessToken) + } else { + throw err + } + } + } catch (err) { + if ( + err instanceof Error && + 'type' in err && + err.type === 'popup_closed' + ) { + // user closed the auth popup, ignore + } else { + setAccessToken(null) + uppy.log(err) + } + } finally { + setLoading(false) + } + } + + async function logout() { + const { accessToken } = store.getSnapshot() + if (accessToken) { + await doLogout(accessToken) + setAccessToken(null) + pickingSession = undefined + } + } + + function reset() { + abortController.abort() + + pickingSession = undefined + initPromise = undefined + store.setState((s) => ({ ...s, accessToken: undefined, loading: false })) + } + + return { + init, + show: showPicker, + reset, + logout, + } +} diff --git a/packages/@uppy/companion-client/src/index.ts b/packages/@uppy/companion-client/src/index.ts index e54b8e978..9542549e7 100644 --- a/packages/@uppy/companion-client/src/index.ts +++ b/packages/@uppy/companion-client/src/index.ts @@ -4,6 +4,25 @@ export type { CompanionPluginOptions } from './CompanionPluginOptions.js' export { default as getAllowedHosts } from './getAllowedHosts.js' +export type { + GooglePickerOptions, + GooglePickerState, + GooglePickerType, + MediaItem, + MediaItemBase, + PhotoMediaItem, + PickedDriveItem, + PickedItemBase, + PickedPhotosItem, + PickingSession, + UnspecifiedMediaItem, + VideoMediaItem, +} from './googlePicker.js' +export { + createGooglePickerController, + createGooglePickerStoreAdapter, + InvalidTokenError, +} from './googlePicker.js' export { default as Provider } from './Provider.js' export { default as RequestClient, diff --git a/packages/@uppy/companion-client/tsconfig.build.json b/packages/@uppy/companion-client/tsconfig.build.json index 389eb1387..a4fd7f396 100644 --- a/packages/@uppy/companion-client/tsconfig.build.json +++ b/packages/@uppy/companion-client/tsconfig.build.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.shared", "compilerOptions": { "outDir": "./lib", - "rootDir": "./src" + "rootDir": "./src", + "types": ["google.accounts", "google.picker", "gapi"] }, "include": ["./src/**/*.*"], "exclude": ["./src/**/*.test.ts"], diff --git a/packages/@uppy/companion-client/tsconfig.json b/packages/@uppy/companion-client/tsconfig.json index 991d79cbd..64bdee9d4 100644 --- a/packages/@uppy/companion-client/tsconfig.json +++ b/packages/@uppy/companion-client/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.shared", "compilerOptions": { "emitDeclarationOnly": false, - "noEmit": true + "noEmit": true, + "types": ["google.accounts", "google.picker", "gapi"] }, "include": ["./package.json", "./src/**/*.*"], "references": [ diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 54754202a..7346e7cfb 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -52,7 +52,10 @@ "typescript": "^5.8.3" }, "peerDependencies": { + "@uppy/companion-client": "workspace:^", "@uppy/core": "workspace:^", + "@uppy/google-drive-picker": "workspace:^", + "@uppy/google-photos-picker": "workspace:^", "@uppy/image-editor": "workspace:^", "@uppy/screen-capture": "workspace:^", "@uppy/webcam": "workspace:^" diff --git a/packages/@uppy/components/src/googlePicker.ts b/packages/@uppy/components/src/googlePicker.ts new file mode 100644 index 000000000..16a4af24a --- /dev/null +++ b/packages/@uppy/components/src/googlePicker.ts @@ -0,0 +1,53 @@ +import { + createGooglePickerStoreAdapter, + type GooglePickerType, +} from '@uppy/companion-client' +import type Uppy from '@uppy/core' +import type GoogleDrivePicker from '@uppy/google-drive-picker' +import type GooglePhotosPicker from '@uppy/google-photos-picker' + +export function createGooglePickerPluginAdapter( + uppy: Uppy, + pickerType: GooglePickerType, +) { + const pluginName = + pickerType === 'drive' ? 'GoogleDrivePicker' : 'GooglePhotosPicker' + const plugin = uppy.getPlugin< + GoogleDrivePicker | GooglePhotosPicker + >(pluginName) + if (!plugin) { + throw new Error(`${pluginName} plugin is not registered in Uppy instance`) + } + + const { getPluginState, setPluginState, opts } = plugin + + const { + companionUrl, + companionCookiesRule, + companionHeaders, + companionKeysParams, + clientId, + } = opts + + const apiKey = 'apiKey' in opts ? opts.apiKey : undefined + const appId = 'appId' in opts ? opts.appId : undefined + const { requestClientId } = plugin + + return { + store: createGooglePickerStoreAdapter({ + uppy, + getPluginState, + setPluginState, + }), + opts: { + companionUrl, + companionCookiesRule, + companionHeaders, + companionKeysParams, + clientId, + apiKey, + appId, + requestClientId, + }, + } +} diff --git a/packages/@uppy/components/src/hooks/googlePicker.ts b/packages/@uppy/components/src/hooks/googlePicker.ts deleted file mode 100644 index 7c212d89d..000000000 --- a/packages/@uppy/components/src/hooks/googlePicker.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { RequestClientOptions } from '@uppy/companion-client' -import { - type CompanionPluginOptions, - RequestClient, - tokenStorage, -} from '@uppy/companion-client' -import type Uppy from '@uppy/core' -import { - authorize, - logout as doLogout, - ensureScriptsInjected, - InvalidTokenError, - mapPickerFile, - type PickedItem, - type PickingSession, - pollPickingSession, - showDrivePicker, - showPhotosPicker, -} from '@uppy/provider-views' - -export type GooglePickerType = 'drive' | 'photos' - -/** - * A simple react-like immutable store that can be used with useSyncExternalStore and the like - */ -function createStore(initialState: T) { - let state = initialState - const listeners = new Set<(state: T) => void>() - - function getSnapshot() { - return state - } - - function setState(updater: T | ((prevState: T) => T)) { - state = typeof updater === 'function' ? updater(state) : updater - - listeners.forEach((listener) => listener(state)) - } - - function subscribe(listener: (state: T) => void) { - listeners.add(listener) - return () => listeners.delete(listener) - } - - return { - getSnapshot, - setState, - subscribe, - } -} - -export interface GooglePickerOptions - extends RequestClientOptions, - Pick { - pickerType: GooglePickerType - clientId: string - requestClientId?: string - apiKey?: string - appId?: string -} - -export function createGooglePickerController({ - uppy, - storage: persistentStore = tokenStorage, - pickerType, - companionUrl, - companionHeaders, - companionCookiesRule, - requestClientId = pickerType === 'drive' - ? 'GoogleDrivePicker' - : 'GooglePhotosPicker', - clientId, - apiKey, - appId, -}: { - uppy: Uppy -} & GooglePickerOptions) { - const storageKey = `uppy:google-${pickerType}-picker:accessToken` - - const store = createStore({ - loading: false, - accessToken: undefined as string | null | undefined, - }) - - let abortController = new AbortController() - let initPromise: Promise | undefined - let pickingSession: PickingSession | undefined - - const handleFilesPicked = async ( - files: PickedItem[], - accessToken: string, - ) => { - uppy.addFiles( - files.map((file) => - mapPickerFile({ requestClientId, accessToken, companionUrl }, file), - ), - ) - } - - function setAccessToken(newAccessToken: string | null) { - store.setState((s) => ({ ...s, accessToken: newAccessToken })) - if (newAccessToken == null) { - return persistentStore.removeItem(storageKey) - } - return persistentStore.setItem(storageKey, newAccessToken) - } - - function setLoading(v: boolean) { - store.setState((s) => ({ ...s, loading: v })) - } - - async function init() { - abortController = new AbortController() - - setAccessToken(await persistentStore.getItem(storageKey)) - - abortController.signal.throwIfAborted() - - // For google photos we need to continuously poll the current picking session - if (pickerType === 'photos') { - pollPickingSession({ - getPickingSession: () => pickingSession, - getAccessToken: () => store.getSnapshot().accessToken, - onPickingSessionClear: () => { - pickingSession = undefined - }, - signal: abortController.signal, - onFilesPicked: handleFilesPicked, - onError: (err) => uppy.log(err), - }) - } - } - - async function showPicker() { - if (initPromise == null) initPromise = init() - await initPromise - - let newAccessToken = store.getSnapshot().accessToken - - const doShowPicker = async (token: string) => { - if (pickerType === 'drive') { - if (apiKey == null || appId == null) - throw new TypeError( - 'apiKey and appId are required for Google Drive picker', - ) - await showDrivePicker({ - token, - apiKey, - appId, - onFilesPicked: handleFilesPicked, - signal: abortController.signal, - onLoadingChange: (isLoading: boolean) => setLoading(isLoading), - onError: (err: unknown) => { - uppy.log(err) - uppy.info(uppy.i18n('failedToAddFiles'), 'error') - }, - }) - } else { - // photos - const onPickingSessionChange = (newPickingSession: PickingSession) => { - pickingSession = newPickingSession - } - await showPhotosPicker({ - token, - pickingSession, - onPickingSessionChange, - signal: abortController.signal, - }) - } - } - - setLoading(true) - try { - try { - const client = new RequestClient(uppy, { - companionUrl, - companionHeaders, - companionCookiesRule, - }) - - uppy.registerRequestClient(requestClientId, client) - - await ensureScriptsInjected(pickerType) - - if (newAccessToken == null) { - newAccessToken = await authorize({ clientId, pickerType }) - } - if (newAccessToken == null) throw new Error() - - await doShowPicker(newAccessToken) - setAccessToken(newAccessToken) - } catch (err) { - if (err instanceof InvalidTokenError) { - uppy.log('Token is invalid or expired, reauthenticating') - newAccessToken = await authorize({ - pickerType, - accessToken: newAccessToken, - clientId, - }) - // now try again: - await doShowPicker(newAccessToken) - setAccessToken(newAccessToken) - } else { - throw err - } - } - } catch (err) { - if ( - err instanceof Error && - 'type' in err && - err.type === 'popup_closed' - ) { - // user closed the auth popup, ignore - } else { - setAccessToken(null) - uppy.log(err) - } - } finally { - setLoading(false) - } - } - - async function logout() { - const { accessToken } = store.getSnapshot() - if (accessToken) { - await doLogout(accessToken) - setAccessToken(null) - pickingSession = undefined - } - } - - function reset() { - abortController.abort() - - pickingSession = undefined - initPromise = undefined - store.setState((s) => ({ ...s, accessToken: undefined, loading: false })) - } - - return { - store, - show: showPicker, - reset, - logout, - } -} diff --git a/packages/@uppy/components/src/index.ts b/packages/@uppy/components/src/index.ts index f8ed2fd71..71be105b8 100644 --- a/packages/@uppy/components/src/index.ts +++ b/packages/@uppy/components/src/index.ts @@ -1,8 +1,15 @@ // Headless components +export { + createGooglePickerController, + type GooglePickerOptions, + type GooglePickerType, +} from '@uppy/companion-client' + export { type DropzoneProps, default as Dropzone } from './Dropzone.js' export { default as FilesGrid, type FilesGridProps } from './FilesGrid.js' export { default as FilesList, type FilesListProps } from './FilesList.js' +export { createGooglePickerPluginAdapter } from './googlePicker.js' // Headless hooks export { createDropzone, @@ -14,11 +21,6 @@ export { type FileInputFunctions, type FileInputProps, } from './hooks/file-input.js' -export { - createGooglePickerController, - type GooglePickerOptions, - type GooglePickerType, -} from './hooks/googlePicker.js' export { createRemoteSourceController, type RemoteSourceKeys, diff --git a/packages/@uppy/components/tsconfig.build.json b/packages/@uppy/components/tsconfig.build.json index 87681cfb3..67a7234aa 100644 --- a/packages/@uppy/components/tsconfig.build.json +++ b/packages/@uppy/components/tsconfig.build.json @@ -16,6 +16,12 @@ { "path": "../google-drive-picker/tsconfig.build.json" }, + { + "path": "../google-photos-picker/tsconfig.build.json" + }, + { + "path": "../companion-client/tsconfig.build.json" + }, { "path": "../image-editor/tsconfig.build.json" }, diff --git a/packages/@uppy/components/tsconfig.json b/packages/@uppy/components/tsconfig.json index e958367c0..4353c1aa8 100644 --- a/packages/@uppy/components/tsconfig.json +++ b/packages/@uppy/components/tsconfig.json @@ -15,6 +15,12 @@ { "path": "../google-drive-picker/tsconfig.build.json" }, + { + "path": "../google-photos-picker/tsconfig.build.json" + }, + { + "path": "../companion-client/tsconfig.build.json" + }, { "path": "../image-editor/tsconfig.build.json" }, diff --git a/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx index 8e0777138..f6229b941 100644 --- a/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx +++ b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx @@ -1,16 +1,12 @@ import { type CompanionPluginOptions, + type GooglePickerState, RequestClient, tokenStorage, } from '@uppy/companion-client' import type { AsyncStore, BaseProviderPlugin, Body, Meta } from '@uppy/core' import { UIPlugin, type Uppy } from '@uppy/core' -import { - GoogleDriveIcon, - GooglePickerView, - mapPickerFile, - type PickedItem, -} from '@uppy/provider-views' +import { GoogleDriveIcon, GooglePickerView } from '@uppy/provider-views' import type { LocaleStrings } from '@uppy/utils' import packageJson from '../package.json' with { type: 'json' } @@ -30,7 +26,7 @@ export type GoogleDrivePickerOptions = CompanionPluginOptions & { } export default class GoogleDrivePicker - extends UIPlugin + extends UIPlugin implements BaseProviderPlugin { static VERSION = packageJson.version @@ -45,6 +41,8 @@ export default class GoogleDrivePicker defaultLocale = locale + requestClientId = GoogleDrivePicker.requestClientId + constructor(uppy: Uppy, opts: GoogleDrivePickerOptions) { super(uppy, opts) this.id = this.opts.id || 'GoogleDrivePicker' @@ -54,13 +52,25 @@ export default class GoogleDrivePicker this.i18nInit() this.title = this.i18n('pluginNameGoogleDrivePicker') - const client = new RequestClient(uppy, { + this.setPluginState({ + loading: false, + accessToken: undefined, + }) + + this.getPluginState = this.getPluginState.bind(this) + this.setPluginState = this.setPluginState.bind(this) + + const requestClient = new RequestClient(uppy, { companionUrl: this.opts.companionUrl, companionHeaders: this.opts.companionHeaders, companionCookiesRule: this.opts.companionCookiesRule, + companionKeysParams: this.opts.companionKeysParams, }) - this.uppy.registerRequestClient(GoogleDrivePicker.requestClientId, client) + this.uppy.registerRequestClient( + GoogleDrivePicker.requestClientId, + requestClient, + ) } install(): void { @@ -74,26 +84,10 @@ export default class GoogleDrivePicker this.unmount() } - private handleFilesPicked = async ( - files: PickedItem[], - accessToken: string, - ) => { - this.uppy.addFiles( - files.map((file) => - mapPickerFile( - { - requestClientId: GoogleDrivePicker.requestClientId, - accessToken, - companionUrl: this.opts.companionUrl, - }, - file, - ), - ), - ) - } - render = () => ( clientId={this.opts.clientId} apiKey={this.opts.apiKey} appId={this.opts.appId} - onFilesPicked={this.handleFilesPicked} + requestClientId={GoogleDrivePicker.requestClientId} + companionUrl={this.opts.companionUrl} /> ) } diff --git a/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx b/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx index 718d60f29..821351bb5 100644 --- a/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx +++ b/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx @@ -1,16 +1,12 @@ import { type CompanionPluginOptions, + type GooglePickerState, RequestClient, tokenStorage, } from '@uppy/companion-client' import type { AsyncStore, BaseProviderPlugin, Body, Meta } from '@uppy/core' import { UIPlugin, type Uppy } from '@uppy/core' -import { - GooglePhotosIcon, - GooglePickerView, - mapPickerFile, - type PickedItem, -} from '@uppy/provider-views' +import { GooglePhotosIcon, GooglePickerView } from '@uppy/provider-views' import type { LocaleStrings } from '@uppy/utils' import packageJson from '../package.json' with { type: 'json' } @@ -28,7 +24,7 @@ export type GooglePhotosPickerOptions = CompanionPluginOptions & { } export default class GooglePhotosPicker - extends UIPlugin + extends UIPlugin implements BaseProviderPlugin { static VERSION = packageJson.version @@ -43,6 +39,8 @@ export default class GooglePhotosPicker defaultLocale = locale + requestClientId = GooglePhotosPicker.requestClientId + constructor(uppy: Uppy, opts: GooglePhotosPickerOptions) { super(uppy, opts) this.id = this.opts.id || 'GooglePhotosPicker' @@ -52,13 +50,25 @@ export default class GooglePhotosPicker this.i18nInit() this.title = this.i18n('pluginNameGooglePhotosPicker') - const client = new RequestClient(uppy, { + this.setPluginState({ + loading: false, + accessToken: undefined, + }) + + this.getPluginState = this.getPluginState.bind(this) + this.setPluginState = this.setPluginState.bind(this) + + const requestClient = new RequestClient(uppy, { companionUrl: this.opts.companionUrl, companionHeaders: this.opts.companionHeaders, companionCookiesRule: this.opts.companionCookiesRule, + companionKeysParams: this.opts.companionKeysParams, }) - this.uppy.registerRequestClient(GooglePhotosPicker.requestClientId, client) + this.uppy.registerRequestClient( + GooglePhotosPicker.requestClientId, + requestClient, + ) } install(): void { @@ -72,32 +82,17 @@ export default class GooglePhotosPicker this.unmount() } - private handleFilesPicked = async ( - files: PickedItem[], - accessToken: string, - ) => { - this.uppy.addFiles( - files.map((file) => - mapPickerFile( - { - requestClientId: GooglePhotosPicker.requestClientId, - accessToken, - companionUrl: this.opts.companionUrl, - }, - file, - ), - ), - ) - } - render = () => ( ) } diff --git a/packages/@uppy/provider-views/package.json b/packages/@uppy/provider-views/package.json index df5470140..39182d288 100644 --- a/packages/@uppy/provider-views/package.json +++ b/packages/@uppy/provider-views/package.json @@ -48,9 +48,6 @@ "preact": "^10.26.10" }, "devDependencies": { - "@types/gapi": "^0.0.47", - "@types/google.accounts": "^0.0.14", - "@types/google.picker": "^0.0.42", "cssnano": "^7.0.7", "jsdom": "^26.1.0", "postcss": "^8.5.6", diff --git a/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx b/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx index 10cc46e8d..adb285a31 100644 --- a/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx +++ b/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx @@ -1,50 +1,77 @@ +import { + type CompanionPluginOptions, + createGooglePickerController, + createGooglePickerStoreAdapter, + type GooglePickerOptions, + type GooglePickerState, +} from '@uppy/companion-client' import type { AsyncStore, Uppy } from '@uppy/core' import type { I18n } from '@uppy/utils' -import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { useSyncExternalStore } from 'preact/compat' +import { useEffect, useMemo, useRef } from 'preact/hooks' import AuthView from '../ProviderView/AuthView.js' -import { - authorize, - ensureScriptsInjected, - InvalidTokenError, - logout, - type PickedItem, - type PickingSession, - pollPickingSession, - showDrivePicker, - showPhotosPicker, -} from './googlePicker.js' import { GoogleDriveIcon, GooglePhotosIcon } from './icons.js' -function useStore( - store: AsyncStore, - key: string, -): [string | undefined | null, (v: string | null) => Promise] { - const [value, setValueState] = useState() - useEffect(() => { - ;(async () => { - setValueState(await store.getItem(key)) - })() - }, [key, store]) +export function useGooglePicker({ + uppy, + requestClientId, + companionUrl, + pickerType, + clientId, + apiKey, + appId, + storage, + store, +}: GooglePickerOptions & { uppy: Uppy } & Pick< + CompanionPluginOptions, + 'companionUrl' + >) { + const { subscribe, getSnapshot } = store - const setValue = useCallback( - async (v: string | null) => { - setValueState(v) - if (v == null) { - return store.removeItem(key) - } - return store.setItem(key, v) - }, - [key, store], + const { reset, init, ...rest } = useMemo( + () => + createGooglePickerController({ + uppy, + requestClientId, + companionUrl, + pickerType, + clientId, + apiKey, + appId, + storage, + store, + }), + [ + uppy, + requestClientId, + clientId, + companionUrl, + pickerType, + apiKey, + appId, + storage, + store, + ], ) - return [value, setValue] + useEffect(() => { + init() + return () => { + reset() + } + }, [reset, init]) + + return { ...useSyncExternalStore(subscribe, getSnapshot), ...rest } } export type GooglePickerViewProps = { + setPluginState: (state: Partial) => void + getPluginState: () => GooglePickerState uppy: Uppy i18n: I18n clientId: string - onFilesPicked: (files: PickedItem[], accessToken: string) => void + requestClientId: string + companionUrl: CompanionPluginOptions['companionUrl'] storage: AsyncStore } & ( | { @@ -59,179 +86,54 @@ export type GooglePickerViewProps = { } ) -// todo rewrite to use useGooglePicker hook internally export default function GooglePickerView({ + getPluginState, + setPluginState, uppy, i18n, clientId, - onFilesPicked, pickerType, apiKey, appId, storage, + requestClientId, + companionUrl, }: GooglePickerViewProps) { - const [loading, setLoading] = useState(false) - const [accessToken, setAccessTokenStored] = useStore( + const store = useMemo( + () => + createGooglePickerStoreAdapter({ uppy, getPluginState, setPluginState }), + [uppy, getPluginState, setPluginState], + ) + + const googlePicker = useGooglePicker({ + uppy, + requestClientId, + companionUrl, + pickerType, + clientId, + apiKey, + appId, storage, - `uppy:google-${pickerType}-picker:accessToken`, - ) + store, + }) - const pickingSessionRef = useRef() - const accessTokenRef = useRef(accessToken) const shownPickerRef = useRef(false) - const setAccessToken = useCallback( - (t: string | null) => { - uppy.log('Access token updated') - setAccessTokenStored(t) - accessTokenRef.current = t - }, - [setAccessTokenStored, uppy], - ) - - // keep access token in sync with the ref - useEffect(() => { - accessTokenRef.current = accessToken - }, [accessToken]) - - const showPicker = useCallback( - async (signal?: AbortSignal) => { - let newAccessToken = accessToken - - const doShowPicker = async (token: string) => { - if (pickerType === 'drive') { - await showDrivePicker({ - token, - apiKey, - appId, - onFilesPicked, - signal, - onLoadingChange: (isLoading: boolean) => setLoading(isLoading), - onError: (err: unknown) => { - uppy.log(err) - uppy.info(i18n('failedToAddFiles'), 'error') - }, - }) - } else { - // photos - const onPickingSessionChange = ( - newPickingSession: PickingSession, - ) => { - pickingSessionRef.current = newPickingSession - } - await showPhotosPicker({ - token, - pickingSession: pickingSessionRef.current, - onPickingSessionChange, - signal, - }) - } - } - - setLoading(true) - try { - try { - await ensureScriptsInjected(pickerType) - - if (newAccessToken == null) { - newAccessToken = await authorize({ clientId, pickerType }) - } - if (newAccessToken == null) throw new Error() - - await doShowPicker(newAccessToken) - shownPickerRef.current = true - setAccessToken(newAccessToken) - } catch (err) { - if (err instanceof InvalidTokenError) { - uppy.log('Token is invalid or expired, reauthenticating') - newAccessToken = await authorize({ - pickerType, - accessToken: newAccessToken, - clientId, - }) - // now try again: - await doShowPicker(newAccessToken) - shownPickerRef.current = true - setAccessToken(newAccessToken) - } else { - throw err - } - } - } catch (err) { - if ( - err instanceof Error && - 'type' in err && - err.type === 'popup_closed' - ) { - // user closed the auth popup, ignore - } else { - setAccessToken(null) - uppy.log(err) - } - } finally { - setLoading(false) - } - }, - [ - accessToken, - apiKey, - appId, - clientId, - onFilesPicked, - pickerType, - setAccessToken, - uppy, - i18n, - ], - ) - - useEffect(() => { - const abortController = new AbortController() - - pollPickingSession({ - getPickingSession: () => pickingSessionRef.current, - getAccessToken: () => accessTokenRef.current, - onPickingSessionClear: () => { - pickingSessionRef.current = undefined - }, - signal: abortController.signal, - onFilesPicked, - onError: (err) => uppy.log(err), - }) - - return () => abortController.abort() - }, [onFilesPicked, uppy]) - useEffect(() => { // when mounting, once we have a token, be nice to the user and automatically show the picker - // accessToken === undefined means not yet loaded from storage, so wait for that first - if (accessToken === undefined || shownPickerRef.current) { - return undefined + // googlePicker.accessToken === undefined means not yet loaded from storage, so wait for that first + if (googlePicker.accessToken === undefined || shownPickerRef.current) { + return } + shownPickerRef.current = true + googlePicker.show() + }, [googlePicker.show, googlePicker.accessToken]) - const abortController = new AbortController() - - showPicker(abortController.signal) - - return () => { - // only abort the picker if it's not yet shown - if (!shownPickerRef.current) abortController.abort() - } - }, [accessToken, showPicker]) - - const handleLogoutClick = useCallback(async () => { - if (accessToken) { - await logout(accessToken) - setAccessToken(null) - pickingSessionRef.current = undefined - } - }, [accessToken, setAccessToken]) - - if (loading) { + if (googlePicker.loading) { return
{i18n('pleaseWait')}...
} - if (accessToken == null) { + if (googlePicker.accessToken == null) { return ( ) } @@ -253,16 +155,16 @@ export default function GooglePickerView({ type="button" className="uppy-u-reset uppy-c-btn uppy-c-btn-primary" style={{ display: 'block', marginBottom: '1em' }} - disabled={loading} - onClick={() => showPicker()} + disabled={googlePicker.loading} + onClick={googlePicker.show} > {pickerType === 'drive' ? i18n('pickFiles') : i18n('pickPhotos')} diff --git a/packages/@uppy/provider-views/src/index.ts b/packages/@uppy/provider-views/src/index.ts index c310f3534..054a2e857 100644 --- a/packages/@uppy/provider-views/src/index.ts +++ b/packages/@uppy/provider-views/src/index.ts @@ -1,27 +1,5 @@ export { default as FilterInput } from './FilterInput.js' export { default as GooglePickerView } from './GooglePicker/GooglePickerView.js' -export type { - MediaItem, - MediaItemBase, - PhotoMediaItem, - PickedDriveItem, - PickedItem, - PickedItemBase, - PickedPhotosItem, - PickingSession, - UnspecifiedMediaItem, - VideoMediaItem, -} from './GooglePicker/googlePicker.js' -export { - authorize, - ensureScriptsInjected, - InvalidTokenError, - logout, - mapPickerFile, - pollPickingSession, - showDrivePicker, - showPhotosPicker, -} from './GooglePicker/googlePicker.js' export { GoogleDriveIcon, GooglePhotosIcon } from './GooglePicker/icons.js' export { default as ProviderViews, diff --git a/packages/@uppy/provider-views/tsconfig.build.json b/packages/@uppy/provider-views/tsconfig.build.json index a4fd7f396..389eb1387 100644 --- a/packages/@uppy/provider-views/tsconfig.build.json +++ b/packages/@uppy/provider-views/tsconfig.build.json @@ -2,8 +2,7 @@ "extends": "../../../tsconfig.shared", "compilerOptions": { "outDir": "./lib", - "rootDir": "./src", - "types": ["google.accounts", "google.picker", "gapi"] + "rootDir": "./src" }, "include": ["./src/**/*.*"], "exclude": ["./src/**/*.test.ts"], diff --git a/packages/@uppy/provider-views/tsconfig.json b/packages/@uppy/provider-views/tsconfig.json index 64bdee9d4..991d79cbd 100644 --- a/packages/@uppy/provider-views/tsconfig.json +++ b/packages/@uppy/provider-views/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../../tsconfig.shared", "compilerOptions": { "emitDeclarationOnly": false, - "noEmit": true, - "types": ["google.accounts", "google.picker", "gapi"] + "noEmit": true }, "include": ["./package.json", "./src/**/*.*"], "references": [ diff --git a/packages/@uppy/react/src/useGooglePicker.ts b/packages/@uppy/react/src/useGooglePicker.ts index bd688b830..eb7235cfd 100644 --- a/packages/@uppy/react/src/useGooglePicker.ts +++ b/packages/@uppy/react/src/useGooglePicker.ts @@ -1,5 +1,6 @@ import { createGooglePickerController, + createGooglePickerPluginAdapter, type GooglePickerOptions, } from '@uppy/components' @@ -8,56 +9,40 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' import { useUppyContext } from './headless/UppyContextProvider.js' export function useGooglePicker({ - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - companionCookiesRule, - companionHeaders, - companionKeysParams, storage, -}: GooglePickerOptions) { +}: Pick) { const { uppy } = useUppyContext() - const { - store: { subscribe, getSnapshot }, - reset, - ...rest - } = useMemo( + const { store, opts } = useMemo( + () => createGooglePickerPluginAdapter(uppy, pickerType), + [uppy, pickerType], + ) + + const { subscribe, getSnapshot } = store + + const { reset, init, ...rest } = useMemo( () => createGooglePickerController({ uppy, - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - companionCookiesRule, - companionHeaders, - companionKeysParams, storage, + store, + ...opts, }), - [ - uppy, - requestClientId, - clientId, - companionUrl, - pickerType, - apiKey, - appId, - companionCookiesRule, - companionHeaders, - companionKeysParams, - storage, - ], + [uppy, pickerType, storage, store, opts], ) - const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + useEffect(() => { + init() - useEffect(() => () => reset(), [reset]) + return () => { + reset() + } + }, [reset, init]) - return { ...store, ...rest } + return { + ...useSyncExternalStore(subscribe, getSnapshot, getSnapshot), + ...rest, + } } diff --git a/packages/@uppy/svelte/src/lib/useGooglePicker.svelte.ts b/packages/@uppy/svelte/src/lib/useGooglePicker.svelte.ts index 9ca27d9e8..ae7d863b3 100644 --- a/packages/@uppy/svelte/src/lib/useGooglePicker.svelte.ts +++ b/packages/@uppy/svelte/src/lib/useGooglePicker.svelte.ts @@ -1,8 +1,9 @@ import { - createGooglePickerController, - type GooglePickerOptions, -} from "@uppy/components"; -import { onDestroy } from "svelte"; + createGooglePickerController, + createGooglePickerPluginAdapter, + type GooglePickerOptions, +} from '@uppy/components'; +import { onDestroy, onMount } from "svelte"; import { getUppyContext } from "./components/headless/uppyContext.js"; import { useExternalStore } from "./useSyncExternalStore.svelte.js"; @@ -17,34 +18,34 @@ type SvelteGooglePickerSnapshot = GooglePickerSnapshot & { }; export function useGooglePicker({ - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - ...restOptions -}: GooglePickerOptions): SvelteGooglePickerSnapshot { + storage, +}: Pick): SvelteGooglePickerSnapshot { const ctx = getUppyContext(); + const { store, opts } = createGooglePickerPluginAdapter(ctx.uppy, pickerType) + + const { subscribe, getSnapshot } = store; + const { - store: { subscribe, getSnapshot }, reset, + init, show, logout, } = createGooglePickerController({ uppy: ctx.uppy, - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - ...restOptions, + store, + storage, + ...opts, }); const state = useExternalStore(getSnapshot, subscribe); + onMount(() => { + init() + }); + onDestroy(() => { reset(); }); diff --git a/packages/@uppy/vue/src/useGooglePicker.ts b/packages/@uppy/vue/src/useGooglePicker.ts index b3875b144..654fee0b9 100644 --- a/packages/@uppy/vue/src/useGooglePicker.ts +++ b/packages/@uppy/vue/src/useGooglePicker.ts @@ -1,8 +1,9 @@ import { createGooglePickerController, + createGooglePickerPluginAdapter, type GooglePickerOptions, } from '@uppy/components' -import { computed, onUnmounted, type ShallowRef } from 'vue' +import { computed, onMounted, onUnmounted, type ShallowRef } from 'vue' import { injectUppyContext } from './headless/context-provider.js' import { useExternalStore } from './useSyncExternalStore.js' @@ -17,36 +18,31 @@ type GooglePickerController = { } export function useGooglePicker({ - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - ...restOptions -}: GooglePickerOptions): ShallowRef< + storage, +}: Pick): ShallowRef< GooglePickerSnapshot & GooglePickerController > { const ctx = injectUppyContext() - const { - store: { subscribe, getSnapshot }, - reset, - show, - logout, - } = createGooglePickerController({ + const { store, opts } = createGooglePickerPluginAdapter(ctx.uppy, pickerType) + + const { subscribe, getSnapshot } = store + + const { reset, init, show, logout } = createGooglePickerController({ uppy: ctx.uppy, - requestClientId, - companionUrl, pickerType, - clientId, - apiKey, - appId, - ...restOptions, + store, + storage, + ...opts, }) const state = useExternalStore(getSnapshot, subscribe) + onMounted(() => { + init() + }) + onUnmounted(() => { reset() }) diff --git a/yarn.lock b/yarn.lock index bb1b78ec0..e13bda8e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8787,6 +8787,9 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/companion-client@workspace:packages/@uppy/companion-client" dependencies: + "@types/gapi": "npm:^0.0.47" + "@types/google.accounts": "npm:^0.0.14" + "@types/google.picker": "npm:^0.0.42" "@uppy/utils": "workspace:^" jsdom: "npm:^26.1.0" namespace-emitter: "npm:^2.0.1" @@ -8881,7 +8884,10 @@ __metadata: tailwindcss: "npm:^4.0.6" typescript: "npm:^5.8.3" peerDependencies: + "@uppy/companion-client": "workspace:^" "@uppy/core": "workspace:^" + "@uppy/google-drive-picker": "workspace:^" + "@uppy/google-photos-picker": "workspace:^" "@uppy/image-editor": "workspace:^" "@uppy/screen-capture": "workspace:^" "@uppy/webcam": "workspace:^" @@ -9185,9 +9191,6 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/provider-views@workspace:packages/@uppy/provider-views" dependencies: - "@types/gapi": "npm:^0.0.47" - "@types/google.accounts": "npm:^0.0.14" - "@types/google.picker": "npm:^0.0.42" "@uppy/utils": "workspace:^" classnames: "npm:^2.2.6" cssnano: "npm:^7.0.7" @@ -13257,6 +13260,8 @@ __metadata: "@types/react": "npm:^19.0.10" "@types/react-dom": "npm:^19.0.4" "@uppy/core": "workspace:*" + "@uppy/google-drive-picker": "workspace:*" + "@uppy/google-photos-picker": "workspace:*" "@uppy/react": "workspace:*" "@uppy/remote-sources": "workspace:*" "@uppy/screen-capture": "workspace:*"