Implement unified cloud picker with support for Google Drive and OneDrive, replacing individual pickers. Add new components for file listing, ingest settings, and file item display. Update package dependencies to include @radix-ui/react-separator.
This commit is contained in:
parent
994b71b40f
commit
9a8818a81b
16 changed files with 1239 additions and 1005 deletions
31
frontend/components/ui/separator.tsx
Normal file
31
frontend/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
|
@ -1855,6 +1856,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slider": {
|
"node_modules/@radix-ui/react-slider": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
import { UnifiedCloudPicker, CloudFile } from "@/components/cloud-picker";
|
||||||
import { useTask } from "@/contexts/task-context"
|
import { useTask } from "@/contexts/task-context";
|
||||||
|
|
||||||
interface GoogleDriveFile {
|
// CloudFile interface is now imported from the unified cloud picker
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
webViewLink?: string;
|
|
||||||
iconLink?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConnectorsPage() {
|
export default function ConnectorsPage() {
|
||||||
const { addTask } = useTask()
|
const { addTask } = useTask();
|
||||||
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<CloudFile[]>([]);
|
||||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||||
const [syncResult, setSyncResult] = useState<{
|
const [syncResult, setSyncResult] = useState<{
|
||||||
processed?: number;
|
processed?: number;
|
||||||
|
|
@ -25,16 +19,19 @@ export default function ConnectorsPage() {
|
||||||
errors?: number;
|
errors?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const handleFileSelection = (files: GoogleDriveFile[]) => {
|
const handleFileSelection = (files: CloudFile[]) => {
|
||||||
setSelectedFiles(files);
|
setSelectedFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async (connector: { connectionId: string, type: string }) => {
|
const handleSync = async (connector: {
|
||||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
connectionId: string;
|
||||||
|
type: string;
|
||||||
setIsSyncing(true)
|
}) => {
|
||||||
setSyncResult(null)
|
if (!connector.connectionId || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const syncBody: {
|
const syncBody: {
|
||||||
connection_id: string;
|
connection_id: string;
|
||||||
|
|
@ -42,54 +39,55 @@ export default function ConnectorsPage() {
|
||||||
selected_files?: string[];
|
selected_files?: string[];
|
||||||
} = {
|
} = {
|
||||||
connection_id: connector.connectionId,
|
connection_id: connector.connectionId,
|
||||||
selected_files: selectedFiles.map(file => file.id)
|
selected_files: selectedFiles.map(file => file.id),
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(syncBody),
|
body: JSON.stringify(syncBody),
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
const taskId = result.task_id
|
const taskId = result.task_id;
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
addTask(taskId)
|
addTask(taskId);
|
||||||
setSyncResult({
|
setSyncResult({
|
||||||
processed: 0,
|
processed: 0,
|
||||||
total: selectedFiles.length,
|
total: selectedFiles.length,
|
||||||
status: 'started'
|
status: "started",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
setSyncResult(result)
|
setSyncResult(result);
|
||||||
} else {
|
} else {
|
||||||
console.error('Sync failed:', result.error)
|
console.error("Sync failed:", result.error);
|
||||||
setSyncResult({ error: result.error || 'Sync failed' })
|
setSyncResult({ error: result.error || "Sync failed" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync error:', error)
|
console.error("Sync error:", error);
|
||||||
setSyncResult({ error: 'Network error occurred' })
|
setSyncResult({ error: "Network error occurred" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false)
|
setIsSyncing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">Connectors</h1>
|
<h1 className="text-2xl font-bold mb-4">Connectors</h1>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
This is a demo page for the Google Drive picker component.
|
This is a demo page for the Google Drive picker component. For full
|
||||||
For full connector functionality, visit the Settings page.
|
connector functionality, visit the Settings page.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<GoogleDrivePicker
|
<UnifiedCloudPicker
|
||||||
|
provider="google_drive"
|
||||||
onFileSelected={handleFileSelection}
|
onFileSelected={handleFileSelection}
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
isAuthenticated={false} // This would come from auth context in real usage
|
isAuthenticated={false} // This would come from auth context in real usage
|
||||||
|
|
@ -99,8 +97,13 @@ export default function ConnectorsPage() {
|
||||||
|
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSync({ connectionId: "google-drive-connection-id", type: "google-drive" })}
|
onClick={() =>
|
||||||
|
handleSync({
|
||||||
|
connectionId: "google-drive-connection-id",
|
||||||
|
type: "google-drive",
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|
@ -110,14 +113,15 @@ export default function ConnectorsPage() {
|
||||||
<>Sync {selectedFiles.length} Selected Items</>
|
<>Sync {selectedFiles.length} Selected Items</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{syncResult && (
|
{syncResult && (
|
||||||
<div className="p-3 bg-gray-100 rounded text-sm">
|
<div className="p-3 bg-gray-100 rounded text-sm">
|
||||||
{syncResult.error ? (
|
{syncResult.error ? (
|
||||||
<div className="text-red-600">Error: {syncResult.error}</div>
|
<div className="text-red-600">Error: {syncResult.error}</div>
|
||||||
) : syncResult.status === 'started' ? (
|
) : syncResult.status === "started" ? (
|
||||||
<div className="text-blue-600">
|
<div className="text-blue-600">
|
||||||
Sync started for {syncResult.total} files. Check the task notification for progress.
|
Sync started for {syncResult.total} files. Check the task
|
||||||
|
notification for progress.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-green-600">
|
<div className="text-green-600">
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,105 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, AlertCircle } from "lucide-react"
|
import { ArrowLeft, AlertCircle } from "lucide-react";
|
||||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
import { UnifiedCloudPicker, CloudFile } from "@/components/cloud-picker";
|
||||||
import { OneDrivePicker } from "@/components/onedrive-picker"
|
import { useTask } from "@/contexts/task-context";
|
||||||
import { useTask } from "@/contexts/task-context"
|
import { Toast } from "@/components/ui/toast";
|
||||||
import { Toast } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
interface GoogleDriveFile {
|
// CloudFile interface is now imported from the unified cloud picker
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType: string
|
|
||||||
webViewLink?: string
|
|
||||||
iconLink?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OneDriveFile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType?: string
|
|
||||||
webUrl?: string
|
|
||||||
driveItem?: {
|
|
||||||
file?: { mimeType: string }
|
|
||||||
folder?: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CloudConnector {
|
interface CloudConnector {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
status: "not_connected" | "connecting" | "connected" | "error"
|
status: "not_connected" | "connecting" | "connected" | "error";
|
||||||
type: string
|
type: string;
|
||||||
connectionId?: string
|
connectionId?: string;
|
||||||
clientId: string
|
clientId: string;
|
||||||
hasAccessToken: boolean
|
hasAccessToken: boolean;
|
||||||
accessTokenError?: string
|
accessTokenError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UploadProviderPage() {
|
export default function UploadProviderPage() {
|
||||||
const params = useParams()
|
const params = useParams();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const provider = params.provider as string
|
const provider = params.provider as string;
|
||||||
const { addTask, tasks } = useTask()
|
const { addTask, tasks } = useTask();
|
||||||
|
|
||||||
const [connector, setConnector] = useState<CloudConnector | null>(null)
|
const [connector, setConnector] = useState<CloudConnector | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null)
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[] | OneDriveFile[]>([])
|
const [selectedFiles, setSelectedFiles] = useState<CloudFile[]>([]);
|
||||||
const [isIngesting, setIsIngesting] = useState<boolean>(false)
|
const [isIngesting, setIsIngesting] = useState<boolean>(false);
|
||||||
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(null)
|
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(
|
||||||
const [showSuccessToast, setShowSuccessToast] = useState(false)
|
null
|
||||||
|
);
|
||||||
|
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConnectorInfo = async () => {
|
const fetchConnectorInfo = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch available connectors to validate the provider
|
// Fetch available connectors to validate the provider
|
||||||
const connectorsResponse = await fetch('/api/connectors')
|
const connectorsResponse = await fetch("/api/connectors");
|
||||||
if (!connectorsResponse.ok) {
|
if (!connectorsResponse.ok) {
|
||||||
throw new Error('Failed to load connectors')
|
throw new Error("Failed to load connectors");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectorsResult = await connectorsResponse.json()
|
const connectorsResult = await connectorsResponse.json();
|
||||||
const providerInfo = connectorsResult.connectors[provider]
|
const providerInfo = connectorsResult.connectors[provider];
|
||||||
|
|
||||||
if (!providerInfo || !providerInfo.available) {
|
if (!providerInfo || !providerInfo.available) {
|
||||||
setError(`Cloud provider "${provider}" is not available or configured.`)
|
setError(
|
||||||
return
|
`Cloud provider "${provider}" is not available or configured.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connector status
|
// Check connector status
|
||||||
const statusResponse = await fetch(`/api/connectors/${provider}/status`)
|
const statusResponse = await fetch(
|
||||||
|
`/api/connectors/${provider}/status`
|
||||||
|
);
|
||||||
if (!statusResponse.ok) {
|
if (!statusResponse.ok) {
|
||||||
throw new Error(`Failed to check ${provider} status`)
|
throw new Error(`Failed to check ${provider} status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusData = await statusResponse.json()
|
const statusData = await statusResponse.json();
|
||||||
const connections = statusData.connections || []
|
const connections = statusData.connections || [];
|
||||||
const activeConnection = connections.find((conn: {is_active: boolean, connection_id: string}) => conn.is_active)
|
const activeConnection = connections.find(
|
||||||
const isConnected = activeConnection !== undefined
|
(conn: { is_active: boolean; connection_id: string }) =>
|
||||||
|
conn.is_active
|
||||||
|
);
|
||||||
|
const isConnected = activeConnection !== undefined;
|
||||||
|
|
||||||
let hasAccessToken = false
|
let hasAccessToken = false;
|
||||||
let accessTokenError: string | undefined = undefined
|
let accessTokenError: string | undefined = undefined;
|
||||||
|
|
||||||
// Try to get access token for connected connectors
|
// Try to get access token for connected connectors
|
||||||
if (isConnected && activeConnection) {
|
if (isConnected && activeConnection) {
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await fetch(`/api/connectors/${provider}/token?connection_id=${activeConnection.connection_id}`)
|
const tokenResponse = await fetch(
|
||||||
|
`/api/connectors/${provider}/token?connection_id=${activeConnection.connection_id}`
|
||||||
|
);
|
||||||
if (tokenResponse.ok) {
|
if (tokenResponse.ok) {
|
||||||
const tokenData = await tokenResponse.json()
|
const tokenData = await tokenResponse.json();
|
||||||
if (tokenData.access_token) {
|
if (tokenData.access_token) {
|
||||||
hasAccessToken = true
|
hasAccessToken = true;
|
||||||
setAccessToken(tokenData.access_token)
|
setAccessToken(tokenData.access_token);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await tokenResponse.json().catch(() => ({ error: 'Token unavailable' }))
|
const errorData = await tokenResponse
|
||||||
accessTokenError = errorData.error || 'Access token unavailable'
|
.json()
|
||||||
|
.catch(() => ({ error: "Token unavailable" }));
|
||||||
|
accessTokenError = errorData.error || "Access token unavailable";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
accessTokenError = 'Failed to fetch access token'
|
accessTokenError = "Failed to fetch access token";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,61 +112,56 @@ export default function UploadProviderPage() {
|
||||||
connectionId: activeConnection?.connection_id,
|
connectionId: activeConnection?.connection_id,
|
||||||
clientId: activeConnection?.client_id,
|
clientId: activeConnection?.client_id,
|
||||||
hasAccessToken,
|
hasAccessToken,
|
||||||
accessTokenError
|
accessTokenError,
|
||||||
})
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load connector info:', error)
|
console.error("Failed to load connector info:", error);
|
||||||
setError(error instanceof Error ? error.message : 'Failed to load connector information')
|
setError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load connector information"
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
fetchConnectorInfo()
|
fetchConnectorInfo();
|
||||||
}
|
}
|
||||||
}, [provider])
|
}, [provider]);
|
||||||
|
|
||||||
// Watch for sync task completion and redirect
|
// Watch for sync task completion and redirect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSyncTaskId) return
|
if (!currentSyncTaskId) return;
|
||||||
|
|
||||||
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId)
|
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId);
|
||||||
|
|
||||||
if (currentTask && currentTask.status === 'completed') {
|
if (currentTask && currentTask.status === "completed") {
|
||||||
// Task completed successfully, show toast and redirect
|
// Task completed successfully, show toast and redirect
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
setShowSuccessToast(true)
|
setShowSuccessToast(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/knowledge')
|
router.push("/knowledge");
|
||||||
}, 2000) // 2 second delay to let user see toast
|
}, 2000); // 2 second delay to let user see toast
|
||||||
} else if (currentTask && currentTask.status === 'failed') {
|
} else if (currentTask && currentTask.status === "failed") {
|
||||||
// Task failed, clear the tracking but don't redirect
|
// Task failed, clear the tracking but don't redirect
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
setCurrentSyncTaskId(null)
|
setCurrentSyncTaskId(null);
|
||||||
}
|
}
|
||||||
}, [tasks, currentSyncTaskId, router])
|
}, [tasks, currentSyncTaskId, router]);
|
||||||
|
|
||||||
const handleFileSelected = (files: GoogleDriveFile[] | OneDriveFile[]) => {
|
const handleFileSelected = (files: CloudFile[]) => {
|
||||||
setSelectedFiles(files)
|
setSelectedFiles(files);
|
||||||
console.log(`Selected ${files.length} files from ${provider}:`, files)
|
console.log(`Selected ${files.length} files from ${provider}:`, files);
|
||||||
// You can add additional handling here like triggering sync, etc.
|
// You can add additional handling here like triggering sync, etc.
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleGoogleDriveFileSelected = (files: GoogleDriveFile[]) => {
|
|
||||||
handleFileSelected(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOneDriveFileSelected = (files: OneDriveFile[]) => {
|
|
||||||
handleFileSelected(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSync = async (connector: CloudConnector) => {
|
const handleSync = async (connector: CloudConnector) => {
|
||||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
if (!connector.connectionId || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
setIsIngesting(true)
|
setIsIngesting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const syncBody: {
|
const syncBody: {
|
||||||
connection_id: string;
|
connection_id: string;
|
||||||
|
|
@ -179,43 +169,43 @@ export default function UploadProviderPage() {
|
||||||
selected_files?: string[];
|
selected_files?: string[];
|
||||||
} = {
|
} = {
|
||||||
connection_id: connector.connectionId,
|
connection_id: connector.connectionId,
|
||||||
selected_files: selectedFiles.map(file => file.id)
|
selected_files: selectedFiles.map(file => file.id),
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(syncBody),
|
body: JSON.stringify(syncBody),
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
const taskIds = result.task_ids
|
const taskIds = result.task_ids;
|
||||||
if (taskIds && taskIds.length > 0) {
|
if (taskIds && taskIds.length > 0) {
|
||||||
const taskId = taskIds[0] // Use the first task ID
|
const taskId = taskIds[0]; // Use the first task ID
|
||||||
addTask(taskId)
|
addTask(taskId);
|
||||||
setCurrentSyncTaskId(taskId)
|
setCurrentSyncTaskId(taskId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Sync failed:', result.error)
|
console.error("Sync failed:", result.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync error:', error)
|
console.error("Sync error:", error);
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const getProviderDisplayName = () => {
|
const getProviderDisplayName = () => {
|
||||||
const nameMap: { [key: string]: string } = {
|
const nameMap: { [key: string]: string } = {
|
||||||
'google_drive': 'Google Drive',
|
google_drive: "Google Drive",
|
||||||
'onedrive': 'OneDrive',
|
onedrive: "OneDrive",
|
||||||
'sharepoint': 'SharePoint'
|
sharepoint: "SharePoint",
|
||||||
}
|
};
|
||||||
return nameMap[provider] || provider
|
return nameMap[provider] || provider;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -227,15 +217,15 @@ export default function UploadProviderPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !connector) {
|
if (error || !connector) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -243,27 +233,29 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">Provider Not Available</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
Provider Not Available
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">{error}</p>
|
<p className="text-muted-foreground mb-4">{error}</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Configure Connectors
|
Configure Connectors
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connector.status !== "connected") {
|
if (connector.status !== "connected") {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -271,29 +263,32 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">{connector.name} Not Connected</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{connector.name} Not Connected
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
You need to connect your {connector.name} account before you can select files.
|
You need to connect your {connector.name} account before you can
|
||||||
|
select files.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Connect {connector.name}
|
Connect {connector.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connector.hasAccessToken) {
|
if (!connector.hasAccessToken) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -301,81 +296,80 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">Access Token Required</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
Access Token Required
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{connector.accessTokenError || `Unable to get access token for ${connector.name}. Try reconnecting your account.`}
|
{connector.accessTokenError ||
|
||||||
|
`Unable to get access token for ${connector.name}. Try reconnecting your account.`}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Reconnect {connector.name}
|
Reconnect {connector.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-3xl p-6">
|
<div className="container mx-auto max-w-3xl p-6">
|
||||||
<div className="mb-6 flex gap-2 items-center">
|
<div className="mb-6 flex gap-2 items-center">
|
||||||
<Button
|
<Button variant="ghost" onClick={() => router.back()}>
|
||||||
variant="ghost"
|
<ArrowLeft className="h-4 w-4 scale-125" />
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 scale-125 mr-2" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
|
<h2 className="text-2xl font-bold">
|
||||||
|
Add from {getProviderDisplayName()}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{connector.type === "google_drive" && (
|
<UnifiedCloudPicker
|
||||||
<GoogleDrivePicker
|
provider={
|
||||||
onFileSelected={handleGoogleDriveFileSelected}
|
connector.type as "google_drive" | "onedrive" | "sharepoint"
|
||||||
selectedFiles={selectedFiles as GoogleDriveFile[]}
|
}
|
||||||
isAuthenticated={true}
|
onFileSelected={handleFileSelected}
|
||||||
accessToken={accessToken || undefined}
|
selectedFiles={selectedFiles}
|
||||||
/>
|
isAuthenticated={true}
|
||||||
)}
|
accessToken={accessToken || undefined}
|
||||||
|
clientId={connector.clientId}
|
||||||
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
|
/>
|
||||||
<OneDrivePicker
|
|
||||||
onFileSelected={handleOneDriveFileSelected}
|
|
||||||
selectedFiles={selectedFiles as OneDriveFile[]}
|
|
||||||
isAuthenticated={true}
|
|
||||||
accessToken={accessToken || undefined}
|
|
||||||
connectorType={connector.type as "onedrive" | "sharepoint"}
|
|
||||||
clientId={connector.clientId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFiles.length > 0 && (
|
<div className="max-w-3xl mx-auto mt-6">
|
||||||
<div className="max-w-3xl mx-auto mt-8">
|
<div className="flex justify-between gap-3 mb-4">
|
||||||
<div className="flex justify-end gap-3 mb-4">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
onClick={() => handleSync(connector)}
|
className=" border bg-transparent border-border rounded-lg text-secondary-foreground"
|
||||||
disabled={selectedFiles.length === 0 || isIngesting}
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
{isIngesting ? (
|
Back
|
||||||
<>Ingesting {selectedFiles.length} Files...</>
|
</Button>
|
||||||
) : (
|
<Button
|
||||||
<>Ingest Files ({selectedFiles.length})</>
|
variant="secondary"
|
||||||
)}
|
onClick={() => handleSync(connector)}
|
||||||
</Button>
|
disabled={selectedFiles.length === 0 || isIngesting}
|
||||||
</div>
|
>
|
||||||
|
{isIngesting ? (
|
||||||
|
<>Ingesting {selectedFiles.length} Files...</>
|
||||||
|
) : (
|
||||||
|
<>Start ingest</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Success toast notification */}
|
{/* Success toast notification */}
|
||||||
<Toast
|
<Toast
|
||||||
message="Ingested successfully!."
|
message="Ingested successfully!."
|
||||||
show={showSuccessToast}
|
show={showSuccessToast}
|
||||||
onHide={() => setShowSuccessToast(false)}
|
onHide={() => setShowSuccessToast(false)}
|
||||||
duration={20000}
|
duration={20000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,101 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
import { UnifiedCloudPicker, CloudFile } from "@/components/cloud-picker";
|
||||||
import { OneDrivePicker } from "@/components/onedrive-picker"
|
import { Loader2 } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
|
|
||||||
interface GoogleDriveFile {
|
// CloudFile interface is now imported from the unified cloud picker
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType: string
|
|
||||||
webViewLink?: string
|
|
||||||
iconLink?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OneDriveFile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType?: string
|
|
||||||
webUrl?: string
|
|
||||||
driveItem?: {
|
|
||||||
file?: { mimeType: string }
|
|
||||||
folder?: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CloudConnector {
|
interface CloudConnector {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode;
|
||||||
status: "not_connected" | "connecting" | "connected" | "error"
|
status: "not_connected" | "connecting" | "connected" | "error";
|
||||||
type: string
|
type: string;
|
||||||
connectionId?: string
|
connectionId?: string;
|
||||||
clientId: string
|
clientId: string;
|
||||||
hasAccessToken: boolean
|
hasAccessToken: boolean;
|
||||||
accessTokenError?: string
|
accessTokenError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloudConnectorsDialogProps {
|
interface CloudConnectorsDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
onFileSelected?: (files: GoogleDriveFile[] | OneDriveFile[], connectorType: string) => void
|
onFileSelected?: (files: CloudFile[], connectorType: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloudConnectorsDialog({
|
export function CloudConnectorsDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onFileSelected
|
onFileSelected,
|
||||||
}: CloudConnectorsDialogProps) {
|
}: CloudConnectorsDialogProps) {
|
||||||
const [connectors, setConnectors] = useState<CloudConnector[]>([])
|
const [connectors, setConnectors] = useState<CloudConnector[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<{[connectorId: string]: GoogleDriveFile[] | OneDriveFile[]}>({})
|
const [selectedFiles, setSelectedFiles] = useState<{
|
||||||
const [connectorAccessTokens, setConnectorAccessTokens] = useState<{[connectorType: string]: string}>({})
|
[connectorId: string]: CloudFile[];
|
||||||
const [activePickerType, setActivePickerType] = useState<string | null>(null)
|
}>({});
|
||||||
|
const [connectorAccessTokens, setConnectorAccessTokens] = useState<{
|
||||||
|
[connectorType: string]: string;
|
||||||
|
}>({});
|
||||||
|
const [activePickerType, setActivePickerType] = useState<string | null>(null);
|
||||||
|
|
||||||
const getConnectorIcon = (iconName: string) => {
|
const getConnectorIcon = (iconName: string) => {
|
||||||
const iconMap: { [key: string]: React.ReactElement } = {
|
const iconMap: { [key: string]: React.ReactElement } = {
|
||||||
'google-drive': (
|
"google-drive": (
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
||||||
G
|
G
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'sharepoint': (
|
sharepoint: (
|
||||||
<div className="w-8 h-8 bg-blue-700 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
<div className="w-8 h-8 bg-blue-700 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
||||||
SP
|
SP
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'onedrive': (
|
onedrive: (
|
||||||
<div className="w-8 h-8 bg-blue-400 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
<div className="w-8 h-8 bg-blue-400 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
||||||
OD
|
OD
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
return iconMap[iconName] || (
|
return (
|
||||||
<div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
iconMap[iconName] || (
|
||||||
?
|
<div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
|
||||||
</div>
|
?
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchConnectorStatuses = useCallback(async () => {
|
const fetchConnectorStatuses = useCallback(async () => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch available connectors from backend
|
// Fetch available connectors from backend
|
||||||
const connectorsResponse = await fetch('/api/connectors')
|
const connectorsResponse = await fetch("/api/connectors");
|
||||||
if (!connectorsResponse.ok) {
|
if (!connectorsResponse.ok) {
|
||||||
throw new Error('Failed to load connectors')
|
throw new Error("Failed to load connectors");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectorsResult = await connectorsResponse.json()
|
const connectorsResult = await connectorsResponse.json();
|
||||||
const connectorTypes = Object.keys(connectorsResult.connectors)
|
const connectorTypes = Object.keys(connectorsResult.connectors);
|
||||||
|
|
||||||
// Filter to only cloud connectors
|
// Filter to only cloud connectors
|
||||||
const cloudConnectorTypes = connectorTypes.filter(type =>
|
const cloudConnectorTypes = connectorTypes.filter(
|
||||||
['google_drive', 'onedrive', 'sharepoint'].includes(type) &&
|
type =>
|
||||||
connectorsResult.connectors[type].available
|
["google_drive", "onedrive", "sharepoint"].includes(type) &&
|
||||||
)
|
connectorsResult.connectors[type].available
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize connectors list
|
// Initialize connectors list
|
||||||
const initialConnectors = cloudConnectorTypes.map(type => ({
|
const initialConnectors = cloudConnectorTypes.map(type => ({
|
||||||
id: type,
|
id: type,
|
||||||
|
|
@ -117,83 +106,94 @@ export function CloudConnectorsDialog({
|
||||||
type: type,
|
type: type,
|
||||||
hasAccessToken: false,
|
hasAccessToken: false,
|
||||||
accessTokenError: undefined,
|
accessTokenError: undefined,
|
||||||
clientId: ""
|
clientId: "",
|
||||||
}))
|
}));
|
||||||
|
|
||||||
setConnectors(initialConnectors)
|
setConnectors(initialConnectors);
|
||||||
|
|
||||||
// Check status for each cloud connector type
|
// Check status for each cloud connector type
|
||||||
for (const connectorType of cloudConnectorTypes) {
|
for (const connectorType of cloudConnectorTypes) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/connectors/${connectorType}/status`)
|
const response = await fetch(
|
||||||
|
`/api/connectors/${connectorType}/status`
|
||||||
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
const connections = data.connections || []
|
const connections = data.connections || [];
|
||||||
const activeConnection = connections.find((conn: { connection_id: string; is_active: boolean }) => conn.is_active)
|
const activeConnection = connections.find(
|
||||||
const isConnected = activeConnection !== undefined
|
(conn: { connection_id: string; is_active: boolean }) =>
|
||||||
|
conn.is_active
|
||||||
let hasAccessToken = false
|
);
|
||||||
let accessTokenError: string | undefined = undefined
|
const isConnected = activeConnection !== undefined;
|
||||||
|
|
||||||
|
let hasAccessToken = false;
|
||||||
|
let accessTokenError: string | undefined = undefined;
|
||||||
|
|
||||||
// Try to get access token for connected connectors
|
// Try to get access token for connected connectors
|
||||||
if (isConnected && activeConnection) {
|
if (isConnected && activeConnection) {
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await fetch(`/api/connectors/${connectorType}/token?connection_id=${activeConnection.connection_id}`)
|
const tokenResponse = await fetch(
|
||||||
|
`/api/connectors/${connectorType}/token?connection_id=${activeConnection.connection_id}`
|
||||||
|
);
|
||||||
if (tokenResponse.ok) {
|
if (tokenResponse.ok) {
|
||||||
const tokenData = await tokenResponse.json()
|
const tokenData = await tokenResponse.json();
|
||||||
if (tokenData.access_token) {
|
if (tokenData.access_token) {
|
||||||
hasAccessToken = true
|
hasAccessToken = true;
|
||||||
setConnectorAccessTokens(prev => ({
|
setConnectorAccessTokens(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[connectorType]: tokenData.access_token
|
[connectorType]: tokenData.access_token,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await tokenResponse.json().catch(() => ({ error: 'Token unavailable' }))
|
const errorData = await tokenResponse
|
||||||
accessTokenError = errorData.error || 'Access token unavailable'
|
.json()
|
||||||
|
.catch(() => ({ error: "Token unavailable" }));
|
||||||
|
accessTokenError =
|
||||||
|
errorData.error || "Access token unavailable";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
accessTokenError = 'Failed to fetch access token'
|
accessTokenError = "Failed to fetch access token";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnectors(prev => prev.map(c =>
|
setConnectors(prev =>
|
||||||
c.type === connectorType
|
prev.map(c =>
|
||||||
? {
|
c.type === connectorType
|
||||||
...c,
|
? {
|
||||||
status: isConnected ? "connected" : "not_connected",
|
...c,
|
||||||
connectionId: activeConnection?.connection_id,
|
status: isConnected ? "connected" : "not_connected",
|
||||||
clientId: activeConnection?.client_id,
|
connectionId: activeConnection?.connection_id,
|
||||||
hasAccessToken,
|
clientId: activeConnection?.client_id,
|
||||||
accessTokenError
|
hasAccessToken,
|
||||||
}
|
accessTokenError,
|
||||||
: c
|
}
|
||||||
))
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to check status for ${connectorType}:`, error)
|
console.error(`Failed to check status for ${connectorType}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load cloud connectors:', error)
|
console.error("Failed to load cloud connectors:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleFileSelection = (connectorId: string, files: GoogleDriveFile[] | OneDriveFile[]) => {
|
const handleFileSelection = (connectorId: string, files: CloudFile[]) => {
|
||||||
setSelectedFiles(prev => ({
|
setSelectedFiles(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[connectorId]: files
|
[connectorId]: files,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
onFileSelected?.(files, connectorId)
|
onFileSelected?.(files, connectorId);
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnectorStatuses()
|
fetchConnectorStatuses();
|
||||||
}, [fetchConnectorStatuses])
|
}, [fetchConnectorStatuses]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -221,19 +221,24 @@ export function CloudConnectorsDialog({
|
||||||
<div className="flex flex-wrap gap-3 justify-center">
|
<div className="flex flex-wrap gap-3 justify-center">
|
||||||
{connectors
|
{connectors
|
||||||
.filter(connector => connector.status === "connected")
|
.filter(connector => connector.status === "connected")
|
||||||
.map((connector) => (
|
.map(connector => (
|
||||||
<Button
|
<Button
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
variant={connector.hasAccessToken ? "default" : "secondary"}
|
variant={
|
||||||
|
connector.hasAccessToken ? "default" : "secondary"
|
||||||
|
}
|
||||||
disabled={!connector.hasAccessToken}
|
disabled={!connector.hasAccessToken}
|
||||||
title={!connector.hasAccessToken ?
|
title={
|
||||||
(connector.accessTokenError || "Access token required - try reconnecting your account")
|
!connector.hasAccessToken
|
||||||
: `Select files from ${connector.name}`}
|
? connector.accessTokenError ||
|
||||||
onClick={(e) => {
|
"Access token required - try reconnecting your account"
|
||||||
e.preventDefault()
|
: `Select files from ${connector.name}`
|
||||||
e.stopPropagation()
|
}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (connector.hasAccessToken) {
|
if (connector.hasAccessToken) {
|
||||||
setActivePickerType(connector.id)
|
setActivePickerType(connector.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px]"
|
||||||
|
|
@ -246,55 +251,46 @@ export function CloudConnectorsDialog({
|
||||||
{connectors.every(c => c.status !== "connected") && (
|
{connectors.every(c => c.status !== "connected") && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<p>No connected cloud providers found.</p>
|
<p>No connected cloud providers found.</p>
|
||||||
<p className="text-sm mt-1">Go to Settings to connect your cloud storage accounts.</p>
|
<p className="text-sm mt-1">
|
||||||
|
Go to Settings to connect your cloud storage accounts.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render pickers inside dialog */}
|
{/* Render unified picker inside dialog */}
|
||||||
{activePickerType && connectors.find(c => c.id === activePickerType) && (() => {
|
{activePickerType &&
|
||||||
const connector = connectors.find(c => c.id === activePickerType)!
|
connectors.find(c => c.id === activePickerType) &&
|
||||||
|
(() => {
|
||||||
if (connector.type === "google_drive") {
|
const connector = connectors.find(
|
||||||
|
c => c.id === activePickerType
|
||||||
|
)!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<GoogleDrivePicker
|
<UnifiedCloudPicker
|
||||||
onFileSelected={(files) => {
|
provider={
|
||||||
handleFileSelection(connector.id, files)
|
connector.type as
|
||||||
setActivePickerType(null)
|
| "google_drive"
|
||||||
|
| "onedrive"
|
||||||
|
| "sharepoint"
|
||||||
|
}
|
||||||
|
onFileSelected={files => {
|
||||||
|
handleFileSelection(connector.id, files);
|
||||||
|
setActivePickerType(null);
|
||||||
}}
|
}}
|
||||||
selectedFiles={selectedFiles[connector.id] as GoogleDriveFile[] || []}
|
selectedFiles={selectedFiles[connector.id] || []}
|
||||||
isAuthenticated={connector.status === "connected"}
|
isAuthenticated={connector.status === "connected"}
|
||||||
accessToken={connectorAccessTokens[connector.type]}
|
accessToken={connectorAccessTokens[connector.type]}
|
||||||
onPickerStateChange={() => {}}
|
onPickerStateChange={() => {}}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connector.type === "onedrive" || connector.type === "sharepoint") {
|
|
||||||
return (
|
|
||||||
<div className="mt-6">
|
|
||||||
<OneDrivePicker
|
|
||||||
onFileSelected={(files) => {
|
|
||||||
handleFileSelection(connector.id, files)
|
|
||||||
setActivePickerType(null)
|
|
||||||
}}
|
|
||||||
selectedFiles={selectedFiles[connector.id] as OneDriveFile[] || []}
|
|
||||||
isAuthenticated={connector.status === "connected"}
|
|
||||||
accessToken={connectorAccessTokens[connector.type]}
|
|
||||||
connectorType={connector.type as "onedrive" | "sharepoint"}
|
|
||||||
clientId={connector.clientId}
|
clientId={connector.clientId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
})()}
|
||||||
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
frontend/src/components/cloud-picker/file-item.tsx
Normal file
67
frontend/src/components/cloud-picker/file-item.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { FileText, Folder, Trash } from "lucide-react";
|
||||||
|
import { CloudFile } from "./types";
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
file: CloudFile;
|
||||||
|
onRemove: (fileId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (mimeType: string) => {
|
||||||
|
if (mimeType.includes("folder")) {
|
||||||
|
return <Folder className="h-6 w-6" />;
|
||||||
|
}
|
||||||
|
return <FileText className="h-6 w-6" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMimeTypeLabel = (mimeType: string) => {
|
||||||
|
const typeMap: { [key: string]: string } = {
|
||||||
|
"application/vnd.google-apps.document": "Google Doc",
|
||||||
|
"application/vnd.google-apps.spreadsheet": "Google Sheet",
|
||||||
|
"application/vnd.google-apps.presentation": "Google Slides",
|
||||||
|
"application/vnd.google-apps.folder": "Folder",
|
||||||
|
"application/pdf": "PDF",
|
||||||
|
"text/plain": "Text",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
|
"Word Doc",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||||
|
"PowerPoint",
|
||||||
|
};
|
||||||
|
|
||||||
|
return typeMap[mimeType] || mimeType?.split("/").pop() || "Document";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return "";
|
||||||
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileItem = ({ file, onRemove }: FileItemProps) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between p-2 rounded-md text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{getFileIcon(file.mimeType)}
|
||||||
|
<span className="truncate font-medium text-sm mr-2">{file.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
|
||||||
|
{getMimeTypeLabel(file.mimeType)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground mr-4" title="file size">
|
||||||
|
{formatFileSize(file.size) || "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Trash
|
||||||
|
className="text-muted-foreground w-5 h-5 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
42
frontend/src/components/cloud-picker/file-list.tsx
Normal file
42
frontend/src/components/cloud-picker/file-list.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CloudFile } from "./types";
|
||||||
|
import { FileItem } from "./file-item";
|
||||||
|
|
||||||
|
interface FileListProps {
|
||||||
|
files: CloudFile[];
|
||||||
|
onClearAll: () => void;
|
||||||
|
onRemoveFile: (fileId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileList = ({
|
||||||
|
files,
|
||||||
|
onClearAll,
|
||||||
|
onRemoveFile,
|
||||||
|
}: FileListProps) => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">Added files</p>
|
||||||
|
<Button
|
||||||
|
onClick={onClearAll}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||||
|
{files.map(file => (
|
||||||
|
<FileItem key={file.id} file={file} onRemove={onRemoveFile} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
frontend/src/components/cloud-picker/index.ts
Normal file
7
frontend/src/components/cloud-picker/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { UnifiedCloudPicker } from "./unified-cloud-picker";
|
||||||
|
export { PickerHeader } from "./picker-header";
|
||||||
|
export { FileList } from "./file-list";
|
||||||
|
export { FileItem } from "./file-item";
|
||||||
|
export { IngestSettings } from "./ingest-settings";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./provider-handlers";
|
||||||
82
frontend/src/components/cloud-picker/ingest-settings.tsx
Normal file
82
frontend/src/components/cloud-picker/ingest-settings.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { ChevronRight, Info } from "lucide-react";
|
||||||
|
|
||||||
|
interface IngestSettingsProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IngestSettings = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: IngestSettingsProps) => (
|
||||||
|
<Collapsible
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
className="border rounded-md p-4 border-muted-foreground/20"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 justify-between w-full -m-4 p-4 rounded-md transition-colors">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
|
||||||
|
isOpen ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Ingest settings</span>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2">
|
||||||
|
<div className="pt-5 space-y-5">
|
||||||
|
<div className="flex items-center gap-4 w-full">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-sm mb-2 font-semibold">Chunk size</div>
|
||||||
|
<Input type="number" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-sm mb-2 font-semibold">Chunk overlap</div>
|
||||||
|
<Input type="number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold pb-2">OCR</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Extracts text from images/PDFs. Ingest is slower when enabled.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm pb-2 font-semibold">
|
||||||
|
Picture descriptions
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Adds captions for images. Ingest is more expensive when enabled.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold pb-2 flex items-center">
|
||||||
|
Embedding model
|
||||||
|
<Info className="w-3.5 h-3.5 text-muted-foreground ml-2" />
|
||||||
|
</div>
|
||||||
|
<Input value="text-embedding-3-small" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
70
frontend/src/components/cloud-picker/picker-header.tsx
Normal file
70
frontend/src/components/cloud-picker/picker-header.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { CloudProvider } from "./types";
|
||||||
|
|
||||||
|
interface PickerHeaderProps {
|
||||||
|
provider: CloudProvider;
|
||||||
|
onAddFiles: () => void;
|
||||||
|
isPickerLoaded: boolean;
|
||||||
|
isPickerOpen: boolean;
|
||||||
|
accessToken?: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProviderName = (provider: CloudProvider): string => {
|
||||||
|
switch (provider) {
|
||||||
|
case "google_drive":
|
||||||
|
return "Google Drive";
|
||||||
|
case "onedrive":
|
||||||
|
return "OneDrive";
|
||||||
|
case "sharepoint":
|
||||||
|
return "SharePoint";
|
||||||
|
default:
|
||||||
|
return "Cloud Storage";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickerHeader = ({
|
||||||
|
provider,
|
||||||
|
onAddFiles,
|
||||||
|
isPickerLoaded,
|
||||||
|
isPickerOpen,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
}: PickerHeaderProps) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
|
||||||
|
Please connect to {getProviderName(provider)} first to select specific
|
||||||
|
files.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center text-center py-8">
|
||||||
|
<p className="text-sm text-primary mb-4">
|
||||||
|
Select files from {getProviderName(provider)} to ingest.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddFiles}
|
||||||
|
disabled={!isPickerLoaded || isPickerOpen || !accessToken}
|
||||||
|
className="bg-foreground text-background hover:bg-foreground/90 font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{isPickerOpen ? "Opening Picker..." : "Add Files"}
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-muted-foreground pt-4">
|
||||||
|
csv, json, pdf,{" "}
|
||||||
|
<a className="underline dark:text-pink-400 text-pink-600">+16 more</a>{" "}
|
||||||
|
<b>150 MB</b> max
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
245
frontend/src/components/cloud-picker/provider-handlers.ts
Normal file
245
frontend/src/components/cloud-picker/provider-handlers.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CloudFile,
|
||||||
|
CloudProvider,
|
||||||
|
GooglePickerData,
|
||||||
|
GooglePickerDocument,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class GoogleDriveHandler {
|
||||||
|
private accessToken: string;
|
||||||
|
private onPickerStateChange?: (isOpen: boolean) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
accessToken: string,
|
||||||
|
onPickerStateChange?: (isOpen: boolean) => void
|
||||||
|
) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.onPickerStateChange = onPickerStateChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPickerApi(): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (typeof window !== "undefined" && window.gapi) {
|
||||||
|
window.gapi.load("picker", {
|
||||||
|
callback: () => resolve(true),
|
||||||
|
onerror: () => resolve(false),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Load Google API script
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://apis.google.com/js/api.js";
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
window.gapi.load("picker", {
|
||||||
|
callback: () => resolve(true),
|
||||||
|
onerror: () => resolve(false),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
script.onerror = () => resolve(false);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPicker(onFileSelected: (files: CloudFile[]) => void): void {
|
||||||
|
if (!window.google?.picker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.onPickerStateChange?.(true);
|
||||||
|
|
||||||
|
const picker = new window.google.picker.PickerBuilder()
|
||||||
|
.addView(window.google.picker.ViewId.DOCS)
|
||||||
|
.addView(window.google.picker.ViewId.FOLDERS)
|
||||||
|
.setOAuthToken(this.accessToken)
|
||||||
|
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
|
||||||
|
.setTitle("Select files from Google Drive")
|
||||||
|
.setCallback(data => this.pickerCallback(data, onFileSelected))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
picker.setVisible(true);
|
||||||
|
|
||||||
|
// Apply z-index fix
|
||||||
|
setTimeout(() => {
|
||||||
|
const pickerElements = document.querySelectorAll(
|
||||||
|
".picker-dialog, .goog-modalpopup"
|
||||||
|
);
|
||||||
|
pickerElements.forEach(el => {
|
||||||
|
(el as HTMLElement).style.zIndex = "10000";
|
||||||
|
});
|
||||||
|
const bgElements = document.querySelectorAll(
|
||||||
|
".picker-dialog-bg, .goog-modalpopup-bg"
|
||||||
|
);
|
||||||
|
bgElements.forEach(el => {
|
||||||
|
(el as HTMLElement).style.zIndex = "9999";
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating picker:", error);
|
||||||
|
this.onPickerStateChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pickerCallback(
|
||||||
|
data: GooglePickerData,
|
||||||
|
onFileSelected: (files: CloudFile[]) => void
|
||||||
|
): Promise<void> {
|
||||||
|
if (data.action === window.google.picker.Action.PICKED) {
|
||||||
|
const files: CloudFile[] = data.docs.map((doc: GooglePickerDocument) => ({
|
||||||
|
id: doc[window.google.picker.Document.ID],
|
||||||
|
name: doc[window.google.picker.Document.NAME],
|
||||||
|
mimeType: doc[window.google.picker.Document.MIME_TYPE],
|
||||||
|
webViewLink: doc[window.google.picker.Document.URL],
|
||||||
|
iconLink: doc[window.google.picker.Document.ICON_URL],
|
||||||
|
size: doc["sizeBytes"] ? parseInt(doc["sizeBytes"]) : undefined,
|
||||||
|
modifiedTime: doc["lastEditedUtc"],
|
||||||
|
isFolder:
|
||||||
|
doc[window.google.picker.Document.MIME_TYPE] ===
|
||||||
|
"application/vnd.google-apps.folder",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Enrich with additional file data if needed
|
||||||
|
if (files.some(f => !f.size && !f.isFolder)) {
|
||||||
|
try {
|
||||||
|
const enrichedFiles = await Promise.all(
|
||||||
|
files.map(async file => {
|
||||||
|
if (!file.size && !file.isFolder) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${file.id}?fields=size,modifiedTime`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const fileDetails = await response.json();
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
size: fileDetails.size
|
||||||
|
? parseInt(fileDetails.size)
|
||||||
|
: undefined,
|
||||||
|
modifiedTime:
|
||||||
|
fileDetails.modifiedTime || file.modifiedTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to fetch file details:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onFileSelected(enrichedFiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to enrich file data:", error);
|
||||||
|
onFileSelected(files);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onFileSelected(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPickerStateChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OneDriveHandler {
|
||||||
|
private accessToken: string;
|
||||||
|
private clientId: string;
|
||||||
|
private provider: CloudProvider;
|
||||||
|
private baseUrl?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
provider: CloudProvider = "onedrive",
|
||||||
|
baseUrl?: string
|
||||||
|
) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.provider = provider;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPickerApi(): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://js.live.net/v7.2/OneDrive.js";
|
||||||
|
script.onload = () => resolve(true);
|
||||||
|
script.onerror = () => resolve(false);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPicker(onFileSelected: (files: CloudFile[]) => void): void {
|
||||||
|
if (!window.OneDrive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.OneDrive.open({
|
||||||
|
clientId: this.clientId,
|
||||||
|
action: "query",
|
||||||
|
multiSelect: true,
|
||||||
|
advanced: {
|
||||||
|
endpointHint: "api.onedrive.com",
|
||||||
|
accessToken: this.accessToken,
|
||||||
|
},
|
||||||
|
success: (response: any) => {
|
||||||
|
const newFiles: CloudFile[] =
|
||||||
|
response.value?.map((item: any, index: number) => ({
|
||||||
|
id: item.id,
|
||||||
|
name:
|
||||||
|
item.name ||
|
||||||
|
`${this.getProviderName()} File ${index + 1} (${item.id.slice(
|
||||||
|
-8
|
||||||
|
)})`,
|
||||||
|
mimeType: item.file?.mimeType || "application/octet-stream",
|
||||||
|
webUrl: item.webUrl || "",
|
||||||
|
downloadUrl: item["@microsoft.graph.downloadUrl"] || "",
|
||||||
|
size: item.size,
|
||||||
|
modifiedTime: item.lastModifiedDateTime,
|
||||||
|
isFolder: !!item.folder,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
onFileSelected(newFiles);
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
console.log("Picker cancelled");
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
console.error("Picker error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProviderName(): string {
|
||||||
|
return this.provider === "sharepoint" ? "SharePoint" : "OneDrive";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createProviderHandler = (
|
||||||
|
provider: CloudProvider,
|
||||||
|
accessToken: string,
|
||||||
|
onPickerStateChange?: (isOpen: boolean) => void,
|
||||||
|
clientId?: string,
|
||||||
|
baseUrl?: string
|
||||||
|
) => {
|
||||||
|
switch (provider) {
|
||||||
|
case "google_drive":
|
||||||
|
return new GoogleDriveHandler(accessToken, onPickerStateChange);
|
||||||
|
case "onedrive":
|
||||||
|
case "sharepoint":
|
||||||
|
if (!clientId) {
|
||||||
|
throw new Error("Client ID required for OneDrive/SharePoint");
|
||||||
|
}
|
||||||
|
return new OneDriveHandler(accessToken, clientId, provider, baseUrl);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported provider: ${provider}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
96
frontend/src/components/cloud-picker/types.ts
Normal file
96
frontend/src/components/cloud-picker/types.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
export interface CloudFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
webViewLink?: string;
|
||||||
|
iconLink?: string;
|
||||||
|
size?: number;
|
||||||
|
modifiedTime?: string;
|
||||||
|
isFolder?: boolean;
|
||||||
|
webUrl?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudProvider = "google_drive" | "onedrive" | "sharepoint";
|
||||||
|
|
||||||
|
export interface UnifiedCloudPickerProps {
|
||||||
|
provider: CloudProvider;
|
||||||
|
onFileSelected: (files: CloudFile[]) => void;
|
||||||
|
selectedFiles?: CloudFile[];
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
accessToken?: string;
|
||||||
|
onPickerStateChange?: (isOpen: boolean) => void;
|
||||||
|
// OneDrive/SharePoint specific props
|
||||||
|
clientId?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoogleAPI {
|
||||||
|
load: (
|
||||||
|
api: string,
|
||||||
|
options: { callback: () => void; onerror?: () => void }
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GooglePickerData {
|
||||||
|
action: string;
|
||||||
|
docs: GooglePickerDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GooglePickerDocument {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gapi: GoogleAPI;
|
||||||
|
google: {
|
||||||
|
picker: {
|
||||||
|
api: {
|
||||||
|
load: (callback: () => void) => void;
|
||||||
|
};
|
||||||
|
PickerBuilder: new () => GooglePickerBuilder;
|
||||||
|
ViewId: {
|
||||||
|
DOCS: string;
|
||||||
|
FOLDERS: string;
|
||||||
|
DOCS_IMAGES_AND_VIDEOS: string;
|
||||||
|
DOCUMENTS: string;
|
||||||
|
PRESENTATIONS: string;
|
||||||
|
SPREADSHEETS: string;
|
||||||
|
};
|
||||||
|
Feature: {
|
||||||
|
MULTISELECT_ENABLED: string;
|
||||||
|
NAV_HIDDEN: string;
|
||||||
|
SIMPLE_UPLOAD_ENABLED: string;
|
||||||
|
};
|
||||||
|
Action: {
|
||||||
|
PICKED: string;
|
||||||
|
CANCEL: string;
|
||||||
|
};
|
||||||
|
Document: {
|
||||||
|
ID: string;
|
||||||
|
NAME: string;
|
||||||
|
MIME_TYPE: string;
|
||||||
|
URL: string;
|
||||||
|
ICON_URL: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
OneDrive?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GooglePickerBuilder {
|
||||||
|
addView: (view: string) => GooglePickerBuilder;
|
||||||
|
setOAuthToken: (token: string) => GooglePickerBuilder;
|
||||||
|
setCallback: (
|
||||||
|
callback: (data: GooglePickerData) => void
|
||||||
|
) => GooglePickerBuilder;
|
||||||
|
enableFeature: (feature: string) => GooglePickerBuilder;
|
||||||
|
setTitle: (title: string) => GooglePickerBuilder;
|
||||||
|
build: () => GooglePicker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GooglePicker {
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
}
|
||||||
173
frontend/src/components/cloud-picker/unified-cloud-picker.tsx
Normal file
173
frontend/src/components/cloud-picker/unified-cloud-picker.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { UnifiedCloudPickerProps, CloudFile } from "./types";
|
||||||
|
import { PickerHeader } from "./picker-header";
|
||||||
|
import { FileList } from "./file-list";
|
||||||
|
import { IngestSettings } from "./ingest-settings";
|
||||||
|
import { createProviderHandler } from "./provider-handlers";
|
||||||
|
|
||||||
|
export const UnifiedCloudPicker = ({
|
||||||
|
provider,
|
||||||
|
onFileSelected,
|
||||||
|
selectedFiles = [],
|
||||||
|
isAuthenticated,
|
||||||
|
accessToken,
|
||||||
|
onPickerStateChange,
|
||||||
|
clientId,
|
||||||
|
baseUrl,
|
||||||
|
}: UnifiedCloudPickerProps) => {
|
||||||
|
const [isPickerLoaded, setIsPickerLoaded] = useState(false);
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
const [isIngestSettingsOpen, setIsIngestSettingsOpen] = useState(false);
|
||||||
|
const [isLoadingBaseUrl, setIsLoadingBaseUrl] = useState(false);
|
||||||
|
const [autoBaseUrl, setAutoBaseUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const effectiveBaseUrl = baseUrl || autoBaseUrl;
|
||||||
|
|
||||||
|
// Auto-detect base URL for OneDrive personal accounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
(provider === "onedrive" || provider === "sharepoint") &&
|
||||||
|
!baseUrl &&
|
||||||
|
accessToken &&
|
||||||
|
!autoBaseUrl
|
||||||
|
) {
|
||||||
|
const getBaseUrl = async () => {
|
||||||
|
setIsLoadingBaseUrl(true);
|
||||||
|
try {
|
||||||
|
setAutoBaseUrl("https://onedrive.live.com/picker");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auto-detect baseUrl failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBaseUrl(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getBaseUrl();
|
||||||
|
}
|
||||||
|
}, [accessToken, baseUrl, autoBaseUrl, provider]);
|
||||||
|
|
||||||
|
// Load picker API
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken || !isAuthenticated) return;
|
||||||
|
|
||||||
|
const loadApi = async () => {
|
||||||
|
try {
|
||||||
|
const handler = createProviderHandler(
|
||||||
|
provider,
|
||||||
|
accessToken,
|
||||||
|
onPickerStateChange,
|
||||||
|
clientId,
|
||||||
|
effectiveBaseUrl
|
||||||
|
);
|
||||||
|
const loaded = await handler.loadPickerApi();
|
||||||
|
setIsPickerLoaded(loaded);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create provider handler:", error);
|
||||||
|
setIsPickerLoaded(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadApi();
|
||||||
|
}, [
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
provider,
|
||||||
|
clientId,
|
||||||
|
effectiveBaseUrl,
|
||||||
|
onPickerStateChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAddFiles = () => {
|
||||||
|
if (!isPickerLoaded || !accessToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((provider === "onedrive" || provider === "sharepoint") && !clientId) {
|
||||||
|
console.error("Client ID required for OneDrive/SharePoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsPickerOpen(true);
|
||||||
|
onPickerStateChange?.(true);
|
||||||
|
|
||||||
|
const handler = createProviderHandler(
|
||||||
|
provider,
|
||||||
|
accessToken,
|
||||||
|
isOpen => {
|
||||||
|
setIsPickerOpen(isOpen);
|
||||||
|
onPickerStateChange?.(isOpen);
|
||||||
|
},
|
||||||
|
clientId,
|
||||||
|
effectiveBaseUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
handler.openPicker((files: CloudFile[]) => {
|
||||||
|
// Merge new files with existing ones, avoiding duplicates
|
||||||
|
const existingIds = new Set(selectedFiles.map(f => f.id));
|
||||||
|
const newFiles = files.filter(f => !existingIds.has(f.id));
|
||||||
|
onFileSelected([...selectedFiles, ...newFiles]);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening picker:", error);
|
||||||
|
setIsPickerOpen(false);
|
||||||
|
onPickerStateChange?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileId: string) => {
|
||||||
|
const updatedFiles = selectedFiles.filter(file => file.id !== fileId);
|
||||||
|
onFileSelected(updatedFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
onFileSelected([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingBaseUrl) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(provider === "onedrive" || provider === "sharepoint") &&
|
||||||
|
!clientId &&
|
||||||
|
isAuthenticated
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
|
||||||
|
Configuration required: Client ID missing for{" "}
|
||||||
|
{provider === "sharepoint" ? "SharePoint" : "OneDrive"}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PickerHeader
|
||||||
|
provider={provider}
|
||||||
|
onAddFiles={handleAddFiles}
|
||||||
|
isPickerLoaded={isPickerLoaded}
|
||||||
|
isPickerOpen={isPickerOpen}
|
||||||
|
accessToken={accessToken}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileList
|
||||||
|
files={selectedFiles}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
onRemoveFile={handleRemoveFile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IngestSettings
|
||||||
|
isOpen={isIngestSettingsOpen}
|
||||||
|
onOpenChange={setIsIngestSettingsOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { FileText, Folder, Plus, Trash2 } from "lucide-react"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
|
|
||||||
interface GoogleDrivePickerProps {
|
|
||||||
onFileSelected: (files: GoogleDriveFile[]) => void
|
|
||||||
selectedFiles?: GoogleDriveFile[]
|
|
||||||
isAuthenticated: boolean
|
|
||||||
accessToken?: string
|
|
||||||
onPickerStateChange?: (isOpen: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleDriveFile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType: string
|
|
||||||
webViewLink?: string
|
|
||||||
iconLink?: string
|
|
||||||
size?: number
|
|
||||||
modifiedTime?: string
|
|
||||||
isFolder?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleAPI {
|
|
||||||
load: (api: string, options: { callback: () => void; onerror?: () => void }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GooglePickerData {
|
|
||||||
action: string
|
|
||||||
docs: GooglePickerDocument[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GooglePickerDocument {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gapi: GoogleAPI
|
|
||||||
google: {
|
|
||||||
picker: {
|
|
||||||
api: {
|
|
||||||
load: (callback: () => void) => void
|
|
||||||
}
|
|
||||||
PickerBuilder: new () => GooglePickerBuilder
|
|
||||||
ViewId: {
|
|
||||||
DOCS: string
|
|
||||||
FOLDERS: string
|
|
||||||
DOCS_IMAGES_AND_VIDEOS: string
|
|
||||||
DOCUMENTS: string
|
|
||||||
PRESENTATIONS: string
|
|
||||||
SPREADSHEETS: string
|
|
||||||
}
|
|
||||||
Feature: {
|
|
||||||
MULTISELECT_ENABLED: string
|
|
||||||
NAV_HIDDEN: string
|
|
||||||
SIMPLE_UPLOAD_ENABLED: string
|
|
||||||
}
|
|
||||||
Action: {
|
|
||||||
PICKED: string
|
|
||||||
CANCEL: string
|
|
||||||
}
|
|
||||||
Document: {
|
|
||||||
ID: string
|
|
||||||
NAME: string
|
|
||||||
MIME_TYPE: string
|
|
||||||
URL: string
|
|
||||||
ICON_URL: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GooglePickerBuilder {
|
|
||||||
addView: (view: string) => GooglePickerBuilder
|
|
||||||
setOAuthToken: (token: string) => GooglePickerBuilder
|
|
||||||
setCallback: (callback: (data: GooglePickerData) => void) => GooglePickerBuilder
|
|
||||||
enableFeature: (feature: string) => GooglePickerBuilder
|
|
||||||
setTitle: (title: string) => GooglePickerBuilder
|
|
||||||
build: () => GooglePicker
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GooglePicker {
|
|
||||||
setVisible: (visible: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoogleDrivePicker({
|
|
||||||
onFileSelected,
|
|
||||||
selectedFiles = [],
|
|
||||||
isAuthenticated,
|
|
||||||
accessToken,
|
|
||||||
onPickerStateChange
|
|
||||||
}: GoogleDrivePickerProps) {
|
|
||||||
const [isPickerLoaded, setIsPickerLoaded] = useState(false)
|
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadPickerApi = () => {
|
|
||||||
if (typeof window !== 'undefined' && window.gapi) {
|
|
||||||
window.gapi.load('picker', {
|
|
||||||
callback: () => {
|
|
||||||
setIsPickerLoaded(true)
|
|
||||||
},
|
|
||||||
onerror: () => {
|
|
||||||
console.error('Failed to load Google Picker API')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Google API script if not already loaded
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (!window.gapi) {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = 'https://apis.google.com/js/api.js'
|
|
||||||
script.async = true
|
|
||||||
script.defer = true
|
|
||||||
script.onload = loadPickerApi
|
|
||||||
script.onerror = () => {
|
|
||||||
console.error('Failed to load Google API script')
|
|
||||||
}
|
|
||||||
document.head.appendChild(script)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (document.head.contains(script)) {
|
|
||||||
document.head.removeChild(script)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
loadPickerApi()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
|
||||||
const openPicker = () => {
|
|
||||||
if (!isPickerLoaded || !accessToken || !window.google?.picker) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsPickerOpen(true)
|
|
||||||
onPickerStateChange?.(true)
|
|
||||||
|
|
||||||
// Create picker with higher z-index and focus handling
|
|
||||||
const picker = new window.google.picker.PickerBuilder()
|
|
||||||
.addView(window.google.picker.ViewId.DOCS)
|
|
||||||
.addView(window.google.picker.ViewId.FOLDERS)
|
|
||||||
.setOAuthToken(accessToken)
|
|
||||||
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
|
|
||||||
.setTitle('Select files from Google Drive')
|
|
||||||
.setCallback(pickerCallback)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
picker.setVisible(true)
|
|
||||||
|
|
||||||
// Apply z-index fix after a short delay to ensure picker is rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
const pickerElements = document.querySelectorAll('.picker-dialog, .goog-modalpopup')
|
|
||||||
pickerElements.forEach(el => {
|
|
||||||
(el as HTMLElement).style.zIndex = '10000'
|
|
||||||
})
|
|
||||||
const bgElements = document.querySelectorAll('.picker-dialog-bg, .goog-modalpopup-bg')
|
|
||||||
bgElements.forEach(el => {
|
|
||||||
(el as HTMLElement).style.zIndex = '9999'
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating picker:', error)
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
onPickerStateChange?.(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickerCallback = async (data: GooglePickerData) => {
|
|
||||||
if (data.action === window.google.picker.Action.PICKED) {
|
|
||||||
const files: GoogleDriveFile[] = data.docs.map((doc: GooglePickerDocument) => ({
|
|
||||||
id: doc[window.google.picker.Document.ID],
|
|
||||||
name: doc[window.google.picker.Document.NAME],
|
|
||||||
mimeType: doc[window.google.picker.Document.MIME_TYPE],
|
|
||||||
webViewLink: doc[window.google.picker.Document.URL],
|
|
||||||
iconLink: doc[window.google.picker.Document.ICON_URL],
|
|
||||||
size: doc['sizeBytes'] ? parseInt(doc['sizeBytes']) : undefined,
|
|
||||||
modifiedTime: doc['lastEditedUtc'],
|
|
||||||
isFolder: doc[window.google.picker.Document.MIME_TYPE] === 'application/vnd.google-apps.folder'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// If size is still missing, try to fetch it via Google Drive API
|
|
||||||
if (accessToken && files.some(f => !f.size && !f.isFolder)) {
|
|
||||||
try {
|
|
||||||
const enrichedFiles = await Promise.all(files.map(async (file) => {
|
|
||||||
if (!file.size && !file.isFolder) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}?fields=size,modifiedTime`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const fileDetails = await response.json()
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
size: fileDetails.size ? parseInt(fileDetails.size) : undefined,
|
|
||||||
modifiedTime: fileDetails.modifiedTime || file.modifiedTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch file details:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
}))
|
|
||||||
onFileSelected(enrichedFiles)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to enrich file data:', error)
|
|
||||||
onFileSelected(files)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onFileSelected(files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
onPickerStateChange?.(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = (fileId: string) => {
|
|
||||||
const updatedFiles = selectedFiles.filter(file => file.id !== fileId)
|
|
||||||
onFileSelected(updatedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFileIcon = (mimeType: string) => {
|
|
||||||
if (mimeType.includes('folder')) {
|
|
||||||
return <Folder className="h-4 w-4" />
|
|
||||||
}
|
|
||||||
return <FileText className="h-4 w-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMimeTypeLabel = (mimeType: string) => {
|
|
||||||
const typeMap: { [key: string]: string } = {
|
|
||||||
'application/vnd.google-apps.document': 'Google Doc',
|
|
||||||
'application/vnd.google-apps.spreadsheet': 'Google Sheet',
|
|
||||||
'application/vnd.google-apps.presentation': 'Google Slides',
|
|
||||||
'application/vnd.google-apps.folder': 'Folder',
|
|
||||||
'application/pdf': 'PDF',
|
|
||||||
'text/plain': 'Text',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Doc',
|
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint'
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeMap[mimeType] || 'Document'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number) => {
|
|
||||||
if (!bytes) return ''
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
if (bytes === 0) return '0 B'
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
|
|
||||||
Please connect to Google Drive first to select specific files.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center text-center p-6">
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Select files from Google Drive to ingest.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={openPicker}
|
|
||||||
disabled={!isPickerLoaded || isPickerOpen || !accessToken}
|
|
||||||
className="bg-foreground text-background hover:bg-foreground/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{isPickerOpen ? 'Opening Picker...' : 'Add Files'}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedFiles.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Added files
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => onFileSelected([])}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-xs h-6"
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
|
||||||
{selectedFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center justify-between p-2 bg-muted/30 rounded-md text-xs"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
{getFileIcon(file.mimeType)}
|
|
||||||
<span className="truncate font-medium">{file.name}</span>
|
|
||||||
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
|
|
||||||
{getMimeTypeLabel(file.mimeType)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
|
|
||||||
<Button
|
|
||||||
onClick={() => removeFile(file.id)}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Plus, Trash2, FileText } from "lucide-react"
|
|
||||||
|
|
||||||
interface OneDrivePickerProps {
|
|
||||||
onFileSelected: (files: SelectedFile[]) => void
|
|
||||||
selectedFiles?: SelectedFile[]
|
|
||||||
isAuthenticated: boolean
|
|
||||||
accessToken?: string
|
|
||||||
connectorType?: "onedrive" | "sharepoint"
|
|
||||||
baseUrl?: string // e.g., "https://tenant.sharepoint.com/sites/sitename" or "https://tenant-my.sharepoint.com"
|
|
||||||
clientId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectedFile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mimeType?: string
|
|
||||||
webUrl?: string
|
|
||||||
downloadUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OneDrivePicker({
|
|
||||||
onFileSelected,
|
|
||||||
selectedFiles = [],
|
|
||||||
isAuthenticated,
|
|
||||||
accessToken,
|
|
||||||
connectorType = "onedrive",
|
|
||||||
baseUrl: providedBaseUrl,
|
|
||||||
clientId
|
|
||||||
}: OneDrivePickerProps) {
|
|
||||||
// Debug all props
|
|
||||||
console.log('All OneDrivePicker props:', {
|
|
||||||
onFileSelected: !!onFileSelected,
|
|
||||||
selectedFiles: selectedFiles?.length,
|
|
||||||
isAuthenticated,
|
|
||||||
accessToken: !!accessToken,
|
|
||||||
connectorType,
|
|
||||||
providedBaseUrl,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
|
||||||
const [autoBaseUrl, setAutoBaseUrl] = useState<string | null>(null)
|
|
||||||
const [isLoadingBaseUrl, setIsLoadingBaseUrl] = useState(false)
|
|
||||||
const baseUrl = providedBaseUrl || autoBaseUrl
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (providedBaseUrl || !accessToken || autoBaseUrl) return
|
|
||||||
|
|
||||||
const getBaseUrl = async () => {
|
|
||||||
setIsLoadingBaseUrl(true)
|
|
||||||
try {
|
|
||||||
// For personal accounts, use the picker URL directly
|
|
||||||
setAutoBaseUrl("https://onedrive.live.com/picker")
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auto-detect baseUrl failed:', error)
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBaseUrl(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBaseUrl()
|
|
||||||
}, [accessToken, providedBaseUrl, autoBaseUrl])
|
|
||||||
|
|
||||||
// Add this loading check before your existing checks:
|
|
||||||
if (isLoadingBaseUrl) {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
|
||||||
<div className="flex flex-col items-center text-center p-6">
|
|
||||||
<p className="text-sm text-gray-600">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPicker = () => {
|
|
||||||
if (!accessToken || !clientId) {
|
|
||||||
console.error('Access token and client ID required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPickerOpen(true)
|
|
||||||
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = 'https://js.live.net/v7.2/OneDrive.js'
|
|
||||||
script.onload = () => {
|
|
||||||
// @ts-ignore
|
|
||||||
const OneDrive = window.OneDrive
|
|
||||||
|
|
||||||
if (OneDrive) {
|
|
||||||
OneDrive.open({
|
|
||||||
clientId: clientId,
|
|
||||||
action: 'query',
|
|
||||||
multiSelect: true,
|
|
||||||
advanced: {
|
|
||||||
endpointHint: 'api.onedrive.com',
|
|
||||||
accessToken: accessToken,
|
|
||||||
},
|
|
||||||
success: (response: any) => {
|
|
||||||
console.log('Raw OneDrive response:', response)
|
|
||||||
|
|
||||||
const newFiles: SelectedFile[] = response.value?.map((item: any, index: number) => ({
|
|
||||||
id: item.id,
|
|
||||||
name: `OneDrive File ${index + 1} (${item.id.slice(-8)})`,
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
webUrl: item.webUrl || '',
|
|
||||||
downloadUrl: ''
|
|
||||||
})) || []
|
|
||||||
|
|
||||||
console.log('Mapped files:', newFiles)
|
|
||||||
onFileSelected([...selectedFiles, ...newFiles])
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
},
|
|
||||||
cancel: () => {
|
|
||||||
console.log('Picker cancelled')
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
},
|
|
||||||
error: (error: any) => {
|
|
||||||
console.error('Picker error:', error)
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.head.appendChild(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePicker = () => {
|
|
||||||
setIsPickerOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = (fileId: string) => {
|
|
||||||
const updatedFiles = selectedFiles.filter(file => file.id !== fileId)
|
|
||||||
onFileSelected(updatedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceName = connectorType === "sharepoint" ? "SharePoint" : "OneDrive"
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
|
||||||
<div className="flex flex-col items-center text-center p-6">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Please connect to {serviceName} first to select files.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken || !baseUrl) {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
|
||||||
<div className="flex flex-col items-center text-center p-6">
|
|
||||||
<p className="text-sm text-gray-600 mb-2">
|
|
||||||
Configuration required
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-amber-600">
|
|
||||||
{!accessToken && "Access token required. "}
|
|
||||||
{!baseUrl && "Base URL required."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{isPickerOpen ? (
|
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">OneDrive Picker is open in popup</h3>
|
|
||||||
<Button
|
|
||||||
onClick={closePicker}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="text-black"
|
|
||||||
style={{ color: '#000000' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Please select your files in the popup window. This window will update when you're done.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
|
||||||
<div className="flex flex-col items-center text-center p-6">
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Select files from {serviceName} to ingest into OpenRAG.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={openPicker}
|
|
||||||
className="bg-blue-600 text-white hover:bg-blue-700 border-0"
|
|
||||||
style={{ backgroundColor: '#2563eb', color: '#ffffff' }}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFiles.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
Selected files ({selectedFiles.length})
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => onFileSelected([])}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-xs h-6"
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
|
||||||
{selectedFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center justify-between p-2 bg-gray-100 rounded-md text-xs"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<FileText className="h-4 w-4 text-gray-600" />
|
|
||||||
<span className="truncate font-medium text-black" style={{ color: '#000000' }}>{file.name}</span>
|
|
||||||
{file.mimeType && (
|
|
||||||
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
|
|
||||||
{file.mimeType.split('/').pop() || 'File'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => removeFile(file.id)}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue