use-google-picker.ts4.7 KBView on GitHub 'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { toast } from 'sonner';
declare global {
interface Window {
gapi: any;
google: any;
}
}
export interface PickedFile {
id: string;
name: string;
url: string;
mimeType: string;
}
export type GooglePickerViewId = 'PRESENTATIONS' | 'DOCS' | 'SPREADSHEETS' | 'ALL';
interface UseGooglePickerOptions {
onPick: (files: PickedFile[]) => void;
multiselect?: boolean;
viewId?: GooglePickerViewId;
/**
* Let the user pick a FOLDER, not just files inside one.
*
* `setIncludeFolders(true)` only makes folders navigable; without
* `setSelectFolderEnabled(true)` a folder can never be the selection. Needed by the
* Drive mount's "Add file", which pins folders as well as files.
*
* Note the asymmetry this creates: under `drive.file` a picked folder conveys **no**
* access to its contents, so a pinned folder stays an empty shell until
* `drive.readonly` is granted. The caller must say so in the UI.
*/
selectFolderEnabled?: boolean;
/**
* Restrict the picker to these exact MIME types, overriding the single type implied by
* `viewId`. Google's picker takes a comma-separated list, so this is how a surface
* offers more than one kind of file — e.g. Docs *and* Slides for the knowledge base.
*/
mimeTypes?: string[];
}
const VIEW_ID_TO_MIME: Record<Exclude<GooglePickerViewId, 'ALL'>, string> = {
PRESENTATIONS: 'application/vnd.google-apps.presentation',
DOCS: 'application/vnd.google-apps.document',
SPREADSHEETS: 'application/vnd.google-apps.spreadsheet',
};
function loadGapiScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window !== 'undefined' && window.gapi) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Google API script'));
document.body.appendChild(script);
});
}
export function useGooglePicker({
onPick,
multiselect = false,
viewId = 'PRESENTATIONS',
selectFolderEnabled = false,
mimeTypes,
}: UseGooglePickerOptions) {
const trpc = useTRPC();
const [isLoading, setIsLoading] = useState(false);
const mimeType = mimeTypes?.length
? mimeTypes.join(',')
: viewId !== 'ALL'
? VIEW_ID_TO_MIME[viewId]
: undefined;
const { data: pickerConfig } = useQuery(
trpc.presentations.getPickerConfig.queryOptions(undefined, { retry: false }),
);
const openPicker = async () => {
if (!pickerConfig || pickerConfig.needsReauth) return;
setIsLoading(true);
try {
await loadGapiScript();
await new Promise<void>((resolve, reject) =>
window.gapi.load('picker', {
callback: resolve,
onerror: (err: unknown) => reject(new Error(String(err))),
timeout: 10000,
ontimeout: () => reject(new Error('Timed out loading Google Picker')),
}),
);
const pickerViewId =
viewId === 'ALL' || mimeTypes?.length
? window.google.picker.ViewId.DOCS
: window.google.picker.ViewId[viewId];
const view = new window.google.picker.DocsView(pickerViewId).setIncludeFolders(true);
if (selectFolderEnabled) {
view.setSelectFolderEnabled(true);
}
if (mimeType) {
view.setMimeTypes(mimeType);
}
const builder = new window.google.picker.PickerBuilder()
.addView(view)
.setOAuthToken(pickerConfig.accessToken)
.setAppId(pickerConfig.appId)
.setOrigin(window.location.protocol + '//' + window.location.host)
.setCallback((data: any) => {
if (data.action !== window.google.picker.Action.PICKED) return;
const files: PickedFile[] = data.docs.map((d: any) => ({
id: d.id,
name: d.name,
url: d.url ?? `https://drive.google.com/file/d/${d.id}`,
mimeType: d.mimeType ?? mimeType,
}));
onPick(files);
});
if (multiselect) {
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
}
if (pickerConfig.apiKey) {
builder.setDeveloperKey(pickerConfig.apiKey);
}
builder.build().setVisible(true);
} catch (err) {
console.error('Google Picker error:', err);
toast.error('Failed to open Google Picker');
} finally {
setIsLoading(false);
}
};
return {
openPicker,
isLoading,
isAvailable: !!pickerConfig && !pickerConfig.needsReauth,
needsReauth: pickerConfig?.needsReauth === true,
};
}