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,
  };
}