mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 21:38:05 -05:00
feat: added file selector for google drive
This commit is contained in:
96
app/api/auth/oauth/drive/files/route.ts
Normal file
96
app/api/auth/oauth/drive/files/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
/**
|
||||
* Get files from Google Drive
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const mimeType = searchParams.get('mimeType')
|
||||
const query = searchParams.get('query') || ''
|
||||
|
||||
if (!credentialId) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if the access token is valid
|
||||
if (!credential.accessToken) {
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build the query parameters for Google Drive API
|
||||
let queryParams = 'trashed=false'
|
||||
|
||||
// Add mimeType filter if provided
|
||||
if (mimeType) {
|
||||
queryParams += `&mimeType='${mimeType}'`
|
||||
}
|
||||
|
||||
// Add search query if provided
|
||||
if (query) {
|
||||
queryParams += `&q=name contains '${query}'`
|
||||
}
|
||||
|
||||
// Fetch files from Google Drive
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return NextResponse.json(
|
||||
{ error: error.error?.message || 'Failed to fetch files from Google Drive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Filter for Google Sheets files if mimeType is for spreadsheets
|
||||
let files = data.files || []
|
||||
|
||||
if (mimeType === 'application/vnd.google-apps.spreadsheet') {
|
||||
files = files.filter(
|
||||
(file: any) => file.mimeType === 'application/vnd.google-apps.spreadsheet'
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ files }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error fetching files from Google Drive:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { db } from '@/db'
|
||||
import { account, workflow } from '@/db/schema'
|
||||
|
||||
@@ -153,3 +154,49 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an OAuth token for a given credential ID
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
|
||||
if (!credentialId) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the access token is valid
|
||||
if (!credential.accessToken) {
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ accessToken: credential.accessToken }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error getting OAuth token:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FileInfo, FileSelector } from '@/components/ui/file-selector'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileSelectorInputProps) {
|
||||
const { getValue, setValue } = useSubBlockStore()
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>('')
|
||||
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null)
|
||||
|
||||
// Get the current value from the store
|
||||
useEffect(() => {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedFileId(value)
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (fileId: string, info?: FileInfo) => {
|
||||
setSelectedFileId(fileId)
|
||||
setFileInfo(info || null)
|
||||
setValue(blockId, subBlock.id, fileId)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
provider={subBlock.provider || 'google-drive'}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
disabled={disabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { CredentialSelector } from './components/credential-selector'
|
||||
import { DateInput } from './components/date-input'
|
||||
import { Dropdown } from './components/dropdown'
|
||||
import { EvalInput } from './components/eval-input'
|
||||
import { FileSelectorInput } from './components/file-selector-input'
|
||||
import { LongInput } from './components/long-input'
|
||||
import { ShortInput } from './components/short-input'
|
||||
import { SliderInput } from './components/slider-input'
|
||||
@@ -147,6 +148,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
|
||||
serviceId={config.serviceId}
|
||||
/>
|
||||
)
|
||||
case 'file-selector':
|
||||
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -44,14 +44,28 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
requiredScopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
placeholder: 'Select Google Sheets account',
|
||||
},
|
||||
// Common Fields
|
||||
// Spreadsheet Selector
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
title: 'Spreadsheet ID',
|
||||
title: 'Select Sheet',
|
||||
type: 'file-selector',
|
||||
layout: 'full',
|
||||
provider: 'google-drive',
|
||||
serviceId: 'google-drive',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
},
|
||||
// Manual Spreadsheet ID (hidden by default)
|
||||
{
|
||||
id: 'manualSpreadsheetId',
|
||||
title: 'Or Enter Spreadsheet ID Manually',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'ID of the spreadsheet (from URL)',
|
||||
condition: { field: 'spreadsheetId', value: '' },
|
||||
},
|
||||
// Range
|
||||
{
|
||||
id: 'range',
|
||||
title: 'Range',
|
||||
@@ -116,13 +130,17 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, values, ...rest } = params
|
||||
const { credential, values, spreadsheetId, manualSpreadsheetId, ...rest } = params
|
||||
|
||||
// Parse values from JSON string to array if it exists
|
||||
const parsedValues = values ? JSON.parse(values as string) : undefined
|
||||
|
||||
// Use the selected spreadsheet ID or the manually entered one
|
||||
const effectiveSpreadsheetId = spreadsheetId || manualSpreadsheetId
|
||||
|
||||
return {
|
||||
...rest,
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
values: parsedValues,
|
||||
credential,
|
||||
}
|
||||
@@ -132,7 +150,8 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
credential: { type: 'string', required: true },
|
||||
spreadsheetId: { type: 'string', required: true },
|
||||
spreadsheetId: { type: 'string', required: false },
|
||||
manualSpreadsheetId: { type: 'string', required: false },
|
||||
range: { type: 'string', required: false },
|
||||
// Write/Update operation inputs
|
||||
values: { type: 'string', required: false },
|
||||
|
||||
@@ -27,6 +27,7 @@ export type SubBlockType =
|
||||
| 'time-input' // Time input
|
||||
| 'oauth-input' // OAuth credential selector
|
||||
| 'webhook-config' // Webhook configuration
|
||||
| 'file-selector' // File selector for Google Drive, etc.
|
||||
|
||||
// Component width setting
|
||||
export type SubBlockLayout = 'full' | 'half'
|
||||
@@ -102,6 +103,8 @@ export interface SubBlockConfig {
|
||||
provider?: string
|
||||
serviceId?: string
|
||||
requiredScopes?: string[]
|
||||
// File selector specific properties
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
// Main block definition
|
||||
|
||||
534
components/ui/file-selector.tsx
Normal file
534
components/ui/file-selector.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, FileIcon, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { GoogleSheetsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Credential,
|
||||
OAUTH_PROVIDERS,
|
||||
OAuthProvider,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceByProviderAndId,
|
||||
getServiceIdFromScopes,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { saveToStorage } from '@/stores/workflows/persistence'
|
||||
|
||||
export interface FileInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
iconLink?: string
|
||||
webViewLink?: string
|
||||
thumbnailLink?: string
|
||||
createdTime?: string
|
||||
modifiedTime?: string
|
||||
size?: string
|
||||
owners?: { displayName: string; emailAddress: string }[]
|
||||
}
|
||||
|
||||
interface FileSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, fileInfo?: FileInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
mimeTypeFilter?: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: FileInfo | null) => void
|
||||
}
|
||||
|
||||
export function FileSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select file',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
mimeTypeFilter,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
}: FileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [files, setFiles] = useState<FileInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch files from Google Drive
|
||||
const fetchFiles = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
})
|
||||
|
||||
if (mimeTypeFilter) {
|
||||
queryParams.append('mimeType', mimeTypeFilter)
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
queryParams.append('query', searchQuery)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/drive/files?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setFiles(data.files || [])
|
||||
|
||||
// If we have a selected file ID, find the file info
|
||||
if (selectedFileId) {
|
||||
const fileInfo = data.files.find((file: FileInfo) => file.id === selectedFileId)
|
||||
if (fileInfo) {
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
} else if (!searchQuery) {
|
||||
// Only reset if this is not a search query
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Error fetching files:', await response.text())
|
||||
setFiles([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, mimeTypeFilter, selectedFileId, onFileInfoChange]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Fetch files when credential is selected
|
||||
useEffect(() => {
|
||||
if (selectedCredentialId) {
|
||||
fetchFiles()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFiles])
|
||||
|
||||
// Update selected file when value changes externally
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
setSelectedFileId(value)
|
||||
|
||||
// Find file info if we have files loaded
|
||||
if (files.length > 0) {
|
||||
const fileInfo = files.find((file) => file.id === value) || null
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
}
|
||||
}
|
||||
}, [value, files, onFileInfoChange])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: FileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
setSelectedFile(file)
|
||||
onChange(file.id, file)
|
||||
onFileInfoChange?.(file)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
const providerId = getProviderId()
|
||||
|
||||
// Store information about the required connection
|
||||
saveToStorage<string>('pending_service_id', effectiveServiceId)
|
||||
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
|
||||
saveToStorage<string>('pending_oauth_return_url', window.location.href)
|
||||
saveToStorage<string>('pending_oauth_provider_id', providerId)
|
||||
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Get provider icon
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className="h-4 w-4" />
|
||||
}
|
||||
|
||||
// For compound providers, find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
for (const service of Object.values(baseProviderConfig.services)) {
|
||||
if (service.providerId === providerName) {
|
||||
return service.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to base provider icon
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
|
||||
// Get provider name
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
try {
|
||||
// First try to get the service by provider and service ID
|
||||
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
|
||||
return service.name
|
||||
} catch (error) {
|
||||
// If that fails, try to get the service by parsing the provider
|
||||
try {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
// For compound providers like 'google-drive', try to find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
const serviceKey = providerName.split('-')[1] || ''
|
||||
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
|
||||
if (key === serviceKey || key === providerName || service.providerId === providerName) {
|
||||
return service.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to provider name if service not found
|
||||
if (baseProviderConfig) {
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Ignore parse error and continue to final fallback
|
||||
}
|
||||
|
||||
// Final fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedFileId('')
|
||||
setSelectedFile(null)
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (value: string) => {
|
||||
if (value.length > 2) {
|
||||
fetchFiles(value)
|
||||
} else if (value.length === 0) {
|
||||
fetchFiles()
|
||||
}
|
||||
}
|
||||
|
||||
// Get file icon based on mime type
|
||||
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
|
||||
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
|
||||
return <GoogleSheetsIcon className={iconSize} />
|
||||
}
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className="font-normal truncate">{selectedFile.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIcon(provider)}
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[300px]" align="start">
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIcon(provider)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder="Search files..." onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span className="ml-2">Loading files...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No accounts connected.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect a {getProviderName(provider)} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No files found.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className="font-normal">{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Files list */}
|
||||
{files.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{getFileIcon(file, 'sm')}
|
||||
<span className="font-normal truncate">{file.name}</span>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add another account option */}
|
||||
{credentials.length > 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<span>Connect Another Account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 hover:bg-muted"
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pr-4">
|
||||
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded">
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xs font-medium truncate">{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink ? (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName(provider)}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user