add Google Picker hook and API types

This commit is contained in:
CREDO23 2026-03-10 20:21:48 +02:00
parent 1e2c54eea6
commit a42a5a936c
4 changed files with 180 additions and 0 deletions

View file

@ -0,0 +1,151 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
export interface PickerItem {
id: string;
name: string;
mimeType: string;
}
export interface PickerResult {
folders: PickerItem[];
files: PickerItem[];
}
interface UseGooglePickerOptions {
connectorId: number;
onPicked: (result: PickerResult) => void;
}
const PICKER_SCRIPT_URL = "https://apis.google.com/js/api.js";
const FOLDER_MIME = "application/vnd.google-apps.folder";
let scriptLoadPromise: Promise<void> | null = null;
let pickerApiPromise: Promise<void> | null = null;
function loadPickerScript(): Promise<void> {
if (scriptLoadPromise) return scriptLoadPromise;
if (typeof window !== "undefined" && window.gapi) {
scriptLoadPromise = Promise.resolve();
return scriptLoadPromise;
}
scriptLoadPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = PICKER_SCRIPT_URL;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => {
scriptLoadPromise = null;
reject(new Error("Failed to load Google Picker script"));
};
document.head.appendChild(script);
});
return scriptLoadPromise;
}
function loadPickerApi(): Promise<void> {
if (pickerApiPromise) return pickerApiPromise;
pickerApiPromise = new Promise<void>((resolve, reject) => {
gapi.load("picker", {
callback: () => resolve(),
onerror: () => {
pickerApiPromise = null;
reject(new Error("Failed to load Google Picker API"));
},
});
});
return pickerApiPromise;
}
export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOptions) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onPickedRef = useRef(onPicked);
onPickedRef.current = onPicked;
const openingRef = useRef(false);
const openPicker = useCallback(async () => {
if (openingRef.current) return;
openingRef.current = true;
setLoading(true);
setError(null);
try {
const [tokenData] = await Promise.all([
connectorsApiService.getDrivePickerToken(connectorId),
loadPickerScript().then(() => loadPickerApi()),
]);
const { access_token, picker_api_key } = tokenData;
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setIncludeFolders(true)
.setSelectFolderEnabled(true);
let pickerInstance: google.picker.Picker | null = null;
const picker = new google.picker.PickerBuilder()
.addView(docsView)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setOAuthToken(access_token)
.setDeveloperKey(picker_api_key)
.setOrigin(window.location.protocol + "//" + window.location.host)
.setTitle("Select files and folders to index")
.setCallback((data: google.picker.ResponseObject) => {
const action = data[google.picker.Response.ACTION];
if (action === google.picker.Action.PICKED) {
const docs = data[google.picker.Response.DOCUMENTS];
if (docs) {
const folders: PickerItem[] = [];
const files: PickerItem[] = [];
for (const doc of docs) {
const mimeType = doc[google.picker.Document.MIME_TYPE] ?? "";
const item: PickerItem = {
id: doc[google.picker.Document.ID],
name: doc[google.picker.Document.NAME] ?? "Untitled",
mimeType,
};
if (mimeType === FOLDER_MIME) {
folders.push(item);
} else {
files.push(item);
}
}
onPickedRef.current({ folders, files });
}
}
if (
action === google.picker.Action.PICKED ||
action === google.picker.Action.CANCEL ||
action === google.picker.Action.ERROR
) {
pickerInstance?.dispose();
pickerInstance = null;
openingRef.current = false;
}
})
.build();
pickerInstance = picker;
picker.setVisible(true);
} catch (err) {
openingRef.current = false;
const msg = err instanceof Error ? err.message : "Failed to open Google Picker";
setError(msg);
console.error("Google Picker error:", err);
} finally {
setLoading(false);
}
}, [connectorId]);
return { openPicker, loading, error };
}

View file

@ -266,6 +266,17 @@ class ConnectorsApiService {
);
};
/**
* Get Google Picker token (access_token + client_id + picker_api_key) for a Drive connector
*/
getDrivePickerToken = async (connectorId: number) => {
return baseApiService.get<{
access_token: string;
client_id: string;
picker_api_key: string;
}>(`/api/v1/connectors/${connectorId}/drive-picker-token`);
};
// =============================================================================
// MCP Connector Methods
// =============================================================================

View file

@ -144,6 +144,8 @@
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/canvas-confetti": "^1.9.0",
"@types/gapi": "^0.0.47",
"@types/google.picker": "^0.0.52",
"@types/node": "^20.19.9",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.8",

View file

@ -372,6 +372,12 @@ importers:
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/gapi':
specifier: ^0.0.47
version: 0.0.47
'@types/google.picker':
specifier: ^0.0.52
version: 0.0.52
'@types/node':
specifier: ^20.19.9
version: 20.19.33
@ -3807,6 +3813,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/gapi@0.0.47':
resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==}
'@types/google.picker@0.0.52':
resolution: {integrity: sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==}
'@types/hast@2.3.10':
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
@ -10486,6 +10498,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/gapi@0.0.47': {}
'@types/google.picker@0.0.52': {}
'@types/hast@2.3.10':
dependencies:
'@types/unist': 2.0.11