From f600ee269508111eea40fca20b71a962a6624902 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 00:29:55 +0200 Subject: [PATCH 1/2] feat: Google Drive tab on upload form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third "Google Drive" tab to the upload form alongside "Your computer" and "Dropbox". Clicking "Choose from Google Drive" opens the Google Picker (loaded on demand from Google's CDN — no npm dep), requests an access token scoped to drive.file (per-file authorization, narrowest possible), and POSTs the picked file + bearer token to the existing POST /api/upload/google_drive endpoint. The same conversion + downloads flow as Dropbox runs from there. The new useGooglePicker hook lazy-loads both apis.google.com/js/api.js (gapi/Picker) and accounts.google.com/gsi/client (Google Identity Services token model), then opens the Picker inside the token callback — opening it outside would race the token initialization and silently fail. Tab is gated on REACT_APP_GOOGLE_CLIENT_ID and REACT_APP_GOOGLE_API_KEY being present. When either is missing the tab is hidden, so deploys without the env vars degrade gracefully to the existing two-tab layout. Server side needs no changes — the endpoint, repository, and migration shipped in #2300 and #2305. Closes the empty "From Google Drive" section on Downloads that #2305 added — without this PR, nothing populates the google_drive_uploads table from the web app. Co-Authored-By: Claude Opus 4.7 --- .../components/UploadForm/UploadForm.test.tsx | 38 +++ .../components/UploadForm/UploadForm.tsx | 188 +++++++++++-- .../UploadForm/UploadSourceTabs.tsx | 22 +- .../UploadForm/hooks/useGooglePicker.test.ts | 157 +++++++++++ .../UploadForm/hooks/useGooglePicker.ts | 254 ++++++++++++++++++ 5 files changed, 641 insertions(+), 18 deletions(-) create mode 100644 web/src/pages/UploadPage/components/UploadForm/hooks/useGooglePicker.test.ts create mode 100644 web/src/pages/UploadPage/components/UploadForm/hooks/useGooglePicker.ts diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx index e968c471b..ef7675445 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx @@ -29,6 +29,44 @@ describe('UploadForm', () => { ); expect(container.querySelector('.null')).toBeNull(); }); + + test('renders the Google Drive tab when env vars are configured', () => { + const previousClient = process.env.REACT_APP_GOOGLE_CLIENT_ID; + const previousKey = process.env.REACT_APP_GOOGLE_API_KEY; + process.env.REACT_APP_GOOGLE_CLIENT_ID = 'test-client'; + process.env.REACT_APP_GOOGLE_API_KEY = 'test-key'; + try { + const { container } = renderUploadForm( + + ); + const tabs = Array.from( + container.querySelectorAll('button[role="tab"]') + ); + expect(tabs.map((b) => b.textContent)).toContain('Google Drive'); + } finally { + process.env.REACT_APP_GOOGLE_CLIENT_ID = previousClient; + process.env.REACT_APP_GOOGLE_API_KEY = previousKey; + } + }); + + test('omits the Google Drive tab when env vars are missing', () => { + const previousClient = process.env.REACT_APP_GOOGLE_CLIENT_ID; + const previousKey = process.env.REACT_APP_GOOGLE_API_KEY; + process.env.REACT_APP_GOOGLE_CLIENT_ID = ''; + process.env.REACT_APP_GOOGLE_API_KEY = ''; + try { + const { container } = renderUploadForm( + + ); + const tabs = Array.from( + container.querySelectorAll('button[role="tab"]') + ); + expect(tabs.map((b) => b.textContent)).not.toContain('Google Drive'); + } finally { + process.env.REACT_APP_GOOGLE_CLIENT_ID = previousClient; + process.env.REACT_APP_GOOGLE_API_KEY = previousKey; + } + }); }); describe('UploadForm analytics events', () => { diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx index 45d8a3da9..8acd07b41 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx @@ -10,6 +10,10 @@ import { getDownloadFileName } from '../../../DownloadsPage/helpers/getDownloadF import { useDrag } from './hooks/useDrag'; import { useFileValidation } from './hooks/useFileValidation'; import { useDropboxChooser, type DropboxFile } from './hooks/useDropboxChooser'; +import { + useGooglePicker, + type GoogleDriveFile, +} from './hooks/useGooglePicker'; import { UploadSourceTabs, type UploadSource } from './UploadSourceTabs'; import { FeedbackWidget } from '../../../../components/FeedbackWidget/FeedbackWidget'; import { useUserLocals } from '../../../../lib/hooks/useUserLocals'; @@ -133,6 +137,19 @@ function DropboxIcon({ className }: Readonly<{ className?: string }>) { ); } +function GoogleDriveIcon({ className }: Readonly<{ className?: string }>) { + return ( + + ); +} + function WarningIcon({ className }: Readonly<{ className?: string }>) { return ( ) { const [dropboxFilename, setDropboxFilename] = useState(null); const [dropboxPending, setDropboxPending] = useState(false); const [dropboxError, setDropboxError] = useState(null); + const [driveFilename, setDriveFilename] = useState(null); + const [drivePending, setDrivePending] = useState(false); + const [driveError, setDriveError] = useState(null); const [source, setSource] = useState('local'); const { data: userLocals } = useUserLocals(); const queryClient = useQueryClient(); @@ -180,6 +200,7 @@ function UploadForm({ setErrorMessage }: Readonly) { const fallbackTimerRef = useRef>(null); const { validation, validate, reset: resetValidation } = useFileValidation(); const { openChooser, isConfigured: isDropboxConfigured } = useDropboxChooser(FORMATS); + const { openPicker, isConfigured: isGoogleDriveConfigured } = useGooglePicker(); const handleStartTrial = async () => { setTrialPending(true); @@ -211,6 +232,8 @@ function UploadForm({ setErrorMessage }: Readonly) { setShowFallback(false); setDropboxFilename(null); setDropboxError(null); + setDriveFilename(null); + setDriveError(null); setSource('local'); resetValidation(); if (fileInputRef.current) { @@ -339,6 +362,88 @@ function UploadForm({ setErrorMessage }: Readonly) { } }; + const handleGoogleDriveFiles = async ( + files: GoogleDriveFile[], + accessToken: string + ) => { + const first = files[0]; + setDriveFilename(first?.name ?? null); + setDriveError(null); + setZoneState('converting'); + fireAnalyticsEvent('upload_started'); + setProgressWidth(10); + setProgressSlow(false); + setShowFallback(false); + try { + const formData = new FormData(); + formData.append('files', JSON.stringify(files)); + formData.append('googleDriveAuth', accessToken); + for (const [key, value] of Object.entries(globalThis.localStorage)) { + formData.append(key, value); + } + const request = await globalThis.fetch('/api/upload/google_drive', { + method: 'post', + body: formData, + }); + if (request.redirected) { + const redirectUrl = new URL(request.url, globalThis.location.origin); + if (redirectUrl.searchParams.get('error') === 'upload_limit_exceeded') { + const isAnonymous = redirectUrl.pathname === '/login'; + setLimitInfo({ isAnonymous, filename: first?.name ?? null }); + setZoneState('limitReached'); + return; + } + handleRedirect(request); + return; + } + if (request.status === 202) { + globalThis.location.href = '/downloads'; + return; + } + if (request.status !== 200) { + const message = await extractErrorMessage(request); + setLocalError(message); + setZoneState('error'); + return; + } + setWarningMessage(request.headers.get('X-Warning')); + setDeckName(resolveDeckName(request.headers)); + const count = parseCardCountHeader(request.headers); + setCardCount(count); + const blob = await request.blob(); + setDownloadLink(globalThis.URL.createObjectURL(blob)); + setProgressWidth(100); + if (count === 0) { + setZoneState('emptyDeck'); + } else { + fireAnalyticsEvent('conversion_success'); + setZoneState('success'); + } + } catch (error) { + setLocalError(toFriendlyThrownError(error)); + setZoneState('error'); + } + }; + + const handleGoogleDriveClick = async () => { + setDriveError(null); + setDrivePending(true); + try { + const outcome = await openPicker(); + if (outcome.kind === 'cancelled') return; + if (outcome.files.length === 0) return; + await handleGoogleDriveFiles(outcome.files, outcome.accessToken); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Couldn't reach Google Drive. Sign in again and retry."; + setDriveError(message); + } finally { + setDrivePending(false); + } + }; + const handleSubmit = async (event: SyntheticEvent) => { event.preventDefault(); setZoneState('converting'); @@ -448,22 +553,34 @@ function UploadForm({ setErrorMessage }: Readonly) { ); - const renderConvertingState = () => ( -
-

- {dropboxFilename ?? displayFilename(fileInputRef.current)} -

-
-
+ const remoteSourceLabel = (): string | null => { + if (driveFilename) return 'Google Drive'; + if (dropboxFilename) return 'Dropbox'; + return null; + }; + + const renderConvertingState = () => { + const remoteFilename = driveFilename ?? dropboxFilename; + const remoteSource = remoteSourceLabel(); + return ( +
+

+ {remoteFilename ?? displayFilename(fileInputRef.current)} +

+
+
+
+

+ {remoteFilename && remoteSource + ? `Fetching ${remoteFilename} from ${remoteSource}` + : 'Making your deck...'} +

-

- {dropboxFilename ? `Fetching ${dropboxFilename} from Dropbox` : 'Making your deck...'} -

-
- ); + ); + }; const renderSuccessState = () => (
@@ -645,8 +762,10 @@ function UploadForm({ setErrorMessage }: Readonly) { return renderIdleState(); }; - const showTabs = isDropboxConfigured && zoneState === 'idle' && !validation; + const anyRemoteSource = isDropboxConfigured || isGoogleDriveConfigured; + const showTabs = anyRemoteSource && zoneState === 'idle' && !validation; const showDropboxPanel = showTabs && source === 'dropbox'; + const showGoogleDrivePanel = showTabs && source === 'google_drive'; const showLocalPanel = !showTabs || source === 'local'; return ( @@ -657,6 +776,7 @@ function UploadForm({ setErrorMessage }: Readonly) { active={source} onChange={setSource} dropboxAvailable={isDropboxConfigured} + googleDriveAvailable={isGoogleDriveConfigured} />
)} @@ -721,6 +841,42 @@ function UploadForm({ setErrorMessage }: Readonly) { {dropboxError}

)} + {showTabs && isGoogleDriveConfigured && ( +
+
+ + + Pick a file from your Google Drive to convert it into a deck + + +
+ {FORMATS.map((fmt) => ( + + {fmt} + + ))} +
+
+
+ )} + {driveError && ( +

+ {driveError} +

+ )} {downloadLink && (