| import { useState, useEffect } from "react"; |
| import { |
| Upload, |
| Trash2, |
| FileText, |
| Check, |
| Loader2, |
| Database, |
| X, |
| ChevronLeft, |
| Link, |
| } from "lucide-react"; |
| import { toast } from "sonner"; |
| import { |
| getDocuments, |
| uploadDocument, |
| processDocument, |
| deleteDocument, |
| getDocumentTypes, |
| getDatabaseClientTypes, |
| connectDatabase, |
| getDatabaseClients, |
| deleteDatabaseClient, |
| ingestDatabaseClient, |
| type ApiDocument, |
| type DocumentStatus, |
| type DocTypeInfo, |
| type DbType, |
| type DbTypeInfo, |
| type DatabaseClient, |
| } from "../../services/api"; |
|
|
| interface KnowledgeManagementProps { |
| open: boolean; |
| onClose: () => void; |
| } |
|
|
| type View = "main" | "db-select" | "db-credentials"; |
|
|
| const LOGO_MAP: Record<string, string> = { |
| postgres: "https://cdn.simpleicons.org/postgresql/336791", |
| mysql: "https://cdn.simpleicons.org/mysql/4479A1", |
| supabase: "https://cdn.simpleicons.org/supabase/3ECF8E", |
| sqlserver: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/microsoftsqlserver/microsoftsqlserver-plain.svg", |
| bigquery: "https://cdn.simpleicons.org/googlebigquery/4285F4", |
| snowflake: "https://cdn.simpleicons.org/snowflake/29B5E8", |
| }; |
|
|
| const getUserId = (): string | null => { |
| const stored = localStorage.getItem("chatbot_user"); |
| if (!stored) return null; |
| return (JSON.parse(stored).user_id as string) ?? null; |
| }; |
|
|
| export default function KnowledgeManagement({ |
| open, |
| onClose, |
| }: KnowledgeManagementProps) { |
| |
| const [docTypes, setDocTypes] = useState<DocTypeInfo[]>([]); |
| const [documents, setDocuments] = useState<ApiDocument[]>([]); |
| const [loadingDocs, setLoadingDocs] = useState(false); |
| const [docsError, setDocsError] = useState<string | null>(null); |
| const [uploading, setUploading] = useState(false); |
| const [uploadError, setUploadError] = useState<string | null>(null); |
| const [processing, setProcessing] = useState<string | null>(null); |
| const [deleting, setDeleting] = useState<string | null>(null); |
|
|
| |
| const [view, setView] = useState<View>("main"); |
| const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null); |
|
|
| |
| const [dbTypeInfos, setDbTypeInfos] = useState<DbTypeInfo[]>([]); |
| const [dbClients, setDbClients] = useState<DatabaseClient[]>([]); |
| const [loadingDbTypes, setLoadingDbTypes] = useState(false); |
| const [ingesting, setIngesting] = useState<string | null>(null); |
| const [deletingClient, setDeletingClient] = useState<string | null>(null); |
|
|
| |
| const [connectionName, setConnectionName] = useState(""); |
| const [dbForm, setDbForm] = useState<Record<string, string | number | boolean>>({}); |
| const [connecting, setConnecting] = useState(false); |
|
|
| useEffect(() => { |
| if (!open) return; |
| const userId = getUserId(); |
| if (!userId) return; |
| loadDocuments(userId); |
| loadDbData(userId); |
| getDocumentTypes() |
| .then((types) => setDocTypes(types.filter((t) => t.status === "active"))) |
| .catch(() => |
| setDocTypes([ |
| { doc_type: "pdf", max_size: 10, status: "active", message: null }, |
| { doc_type: "csv", max_size: 10, status: "active", message: null }, |
| { doc_type: "xlsx", max_size: 10, status: "active", message: null }, |
| ]) |
| ); |
| }, [open]); |
|
|
| const acceptedExtensions = docTypes.map((t) => `.${t.doc_type}`).join(","); |
| const supportedFormatsText = docTypes.map((t) => t.doc_type.toUpperCase()).join(", "); |
|
|
| const loadDbData = async (userId: string) => { |
| setLoadingDbTypes(true); |
| try { |
| const [types, clients] = await Promise.all([ |
| getDatabaseClientTypes(), |
| getDatabaseClients(userId), |
| ]); |
| setDbTypeInfos(types); |
| setDbClients(clients); |
| } catch { |
| |
| } finally { |
| setLoadingDbTypes(false); |
| } |
| }; |
|
|
| const handleClose = () => { |
| setView("main"); |
| setSelectedDbType(null); |
| setConnectionName(""); |
| setDbForm({}); |
| onClose(); |
| }; |
|
|
| |
|
|
| const loadDocuments = async (userId: string) => { |
| setLoadingDocs(true); |
| setDocsError(null); |
| try { |
| setDocuments(await getDocuments(userId)); |
| } catch (err) { |
| setDocsError( |
| err instanceof Error ? err.message : "Failed to load documents" |
| ); |
| } finally { |
| setLoadingDocs(false); |
| } |
| }; |
|
|
| const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| const files = e.target.files; |
| if (!files || files.length === 0) return; |
| const userId = getUserId(); |
| if (!userId) return; |
|
|
| setUploading(true); |
| setUploadError(null); |
|
|
| for (let i = 0; i < files.length; i++) { |
| const file = files[i]; |
| try { |
| const uploadRes = await uploadDocument(userId, file); |
| const newDoc: ApiDocument = { |
| id: uploadRes.data.id, |
| filename: uploadRes.data.filename, |
| status: "pending", |
| file_size: file.size, |
| file_type: file.name.split(".").pop() ?? "", |
| created_at: new Date().toISOString(), |
| }; |
| setDocuments((prev) => [newDoc, ...prev]); |
| await processDocumentById(userId, uploadRes.data.id); |
| } catch (err) { |
| setUploadError(err instanceof Error ? err.message : "Upload failed"); |
| } |
| } |
|
|
| setUploading(false); |
| e.target.value = ""; |
| }; |
|
|
| const processDocumentById = async (userId: string, docId: string) => { |
| setProcessing(docId); |
| setDocuments((prev) => |
| prev.map((d) => |
| d.id === docId ? { ...d, status: "processing" as DocumentStatus } : d |
| ) |
| ); |
| try { |
| await processDocument(userId, docId); |
| setDocuments((prev) => |
| prev.map((d) => |
| d.id === docId ? { ...d, status: "completed" as DocumentStatus } : d |
| ) |
| ); |
| } catch { |
| setDocuments((prev) => |
| prev.map((d) => |
| d.id === docId ? { ...d, status: "failed" as DocumentStatus } : d |
| ) |
| ); |
| } finally { |
| setProcessing(null); |
| } |
| }; |
|
|
| const handleDeleteDocument = async (docId: string) => { |
| const userId = getUserId(); |
| if (!userId) return; |
| setDeleting(docId); |
| try { |
| await deleteDocument(userId, docId); |
| setDocuments((prev) => prev.filter((d) => d.id !== docId)); |
| } catch (err) { |
| console.error("Delete failed:", err); |
| } finally { |
| setDeleting(null); |
| } |
| }; |
|
|
| const deleteAllDocuments = async () => { |
| if (!window.confirm("Are you sure you want to delete all documents?")) |
| return; |
| const userId = getUserId(); |
| if (!userId) return; |
| for (const doc of documents) { |
| try { |
| await deleteDocument(userId, doc.id); |
| } catch { |
| |
| } |
| } |
| setDocuments([]); |
| }; |
|
|
| |
|
|
| const handleDbConnect = async () => { |
| const userId = getUserId(); |
| if (!userId || !selectedDbType || !connectionName.trim()) return; |
| setConnecting(true); |
| try { |
| await connectDatabase(userId, selectedDbType, connectionName.trim(), dbForm); |
| const clients = await getDatabaseClients(userId); |
| setDbClients(clients); |
| toast.success("Database connected successfully"); |
| setView("main"); |
| setConnectionName(""); |
| setDbForm({}); |
| } catch (err) { |
| toast.error( |
| err instanceof Error ? err.message : "Failed to connect to database" |
| ); |
| } finally { |
| setConnecting(false); |
| } |
| }; |
|
|
| const handleIngest = async (clientId: string) => { |
| const userId = getUserId(); |
| if (!userId) return; |
| setIngesting(clientId); |
| try { |
| const res = await ingestDatabaseClient(clientId, userId); |
| toast.success(`Ingested ${res.chunks_ingested} chunks successfully`); |
| } catch (err) { |
| toast.error(err instanceof Error ? err.message : "Ingestion failed"); |
| } finally { |
| setIngesting(null); |
| } |
| }; |
|
|
| const handleDeleteClient = async (clientId: string) => { |
| const userId = getUserId(); |
| if (!userId) return; |
| setDeletingClient(clientId); |
| try { |
| await deleteDatabaseClient(clientId, userId); |
| setDbClients((prev) => prev.filter((c) => c.id !== clientId)); |
| } catch (err) { |
| toast.error(err instanceof Error ? err.message : "Failed to delete connection"); |
| } finally { |
| setDeletingClient(null); |
| } |
| }; |
|
|
| |
|
|
| const formatFileSize = (bytes: number) => { |
| if (bytes < 1024) return bytes + " B"; |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; |
| return (bytes / (1024 * 1024)).toFixed(2) + " MB"; |
| }; |
|
|
| const formatDate = (isoString: string) => { |
| return new Date(isoString).toLocaleString(); |
| }; |
|
|
| const renderStatus = (doc: ApiDocument) => { |
| if (doc.status === "completed") { |
| return ( |
| <div className="flex items-center gap-1.5 text-green-600"> |
| <Check className="w-3.5 h-3.5" /> |
| <span className="text-xs font-medium">Processed</span> |
| </div> |
| ); |
| } |
|
|
| if (doc.status === "processing" || processing === doc.id) { |
| return ( |
| <div className="flex items-center gap-1.5 text-blue-600"> |
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> |
| <span className="text-xs">Processing...</span> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <button |
| onClick={() => { |
| const userId = getUserId(); |
| if (userId) processDocumentById(userId, doc.id); |
| }} |
| disabled={processing === doc.id} |
| className="flex items-center gap-1.5 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white px-3 py-1.5 rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition disabled:opacity-50 disabled:cursor-not-allowed text-xs" |
| > |
| <Database className="w-3.5 h-3.5" /> |
| {doc.status === "failed" ? "Retry Process" : "Process to Knowledge"} |
| </button> |
| ); |
| }; |
|
|
| if (!open) return null; |
|
|
| |
|
|
| const selectedDbInfo = dbTypeInfos.find((d) => d.db_type === selectedDbType); |
|
|
| const headerTitle = |
| view === "db-select" |
| ? "Connect Database" |
| : view === "db-credentials" |
| ? `Connect to ${selectedDbInfo?.display_name ?? selectedDbType}` |
| : "Knowledge Base"; |
|
|
| const headerBack = |
| view === "db-select" |
| ? () => setView("main") |
| : view === "db-credentials" |
| ? () => setView("db-select") |
| : null; |
|
|
| |
|
|
| return ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"> |
| <div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[85vh] flex flex-col"> |
| |
| {/* Header */} |
| <div className="flex items-center justify-between px-5 py-4 border-b border-slate-100"> |
| <div className="flex items-center gap-3"> |
| {headerBack ? ( |
| <button |
| onClick={headerBack} |
| className="p-1.5 -ml-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition" |
| aria-label="Back" |
| > |
| <ChevronLeft className="w-4 h-4" /> |
| </button> |
| ) : ( |
| <div className="w-7 h-7 rounded-lg bg-orange-100 flex items-center justify-center"> |
| <Database className="w-4 h-4 text-[#FF8F00]" /> |
| </div> |
| )} |
| <h2 className="text-sm font-semibold text-slate-900">{headerTitle}</h2> |
| </div> |
| <button |
| onClick={handleClose} |
| className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| |
| {/* Content */} |
| <div className="flex-1 overflow-y-auto px-5 py-4"> |
| |
| {/* ββ VIEW: main ββ */} |
| {view === "main" && ( |
| <div className="space-y-4"> |
| |
| {/* Upload zone */} |
| <label |
| htmlFor="file-upload" |
| className="group flex flex-col items-center gap-2.5 border-2 border-dashed border-slate-200 rounded-xl p-7 cursor-pointer hover:border-[#FF8F00] hover:bg-orange-50/30 transition-colors" |
| > |
| <div className="w-10 h-10 rounded-xl bg-slate-100 group-hover:bg-orange-100 flex items-center justify-center transition-colors"> |
| {uploading ? ( |
| <Loader2 className="w-5 h-5 text-[#FF8F00] animate-spin" /> |
| ) : ( |
| <Upload className="w-5 h-5 text-slate-400 group-hover:text-[#FF8F00] transition-colors" /> |
| )} |
| </div> |
| <div className="text-center"> |
| <p className="text-sm font-medium text-slate-700"> |
| {uploading ? "Uploadingβ¦" : ( |
| <>Drop files, or <span className="text-[#FF8F00]">browse</span></> |
| )} |
| </p> |
| <p className="text-xs text-slate-400 mt-0.5">{supportedFormatsText}</p> |
| </div> |
| <input |
| id="file-upload" |
| type="file" |
| accept={acceptedExtensions} |
| multiple |
| onChange={handleFileUpload} |
| className="hidden" |
| disabled={uploading} |
| /> |
| </label> |
| |
| {uploadError && ( |
| <p className="text-xs text-red-500 bg-red-50 border border-red-100 px-3 py-2 rounded-lg"> |
| {uploadError} |
| </p> |
| )} |
| |
| {/* Connect DB row */} |
| <button |
| onClick={() => setView("db-select")} |
| className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition text-left group" |
| > |
| <div className="w-8 h-8 rounded-lg bg-slate-100 group-hover:bg-slate-200 flex items-center justify-center flex-shrink-0 transition-colors"> |
| <Link className="w-4 h-4 text-slate-500" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-medium text-slate-700">Connect a Database</p> |
| <p className="text-xs text-slate-400">PostgreSQL and more</p> |
| </div> |
| <ChevronLeft className="w-4 h-4 text-slate-300 rotate-180 flex-shrink-0" /> |
| </button> |
| |
| {/* Database connections list */} |
| {dbClients.length > 0 && ( |
| <div> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> |
| Databases Β· {dbClients.length} |
| </span> |
| </div> |
| <div className="space-y-1"> |
| {dbClients.map((client) => ( |
| <div |
| key={client.id} |
| className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group" |
| > |
| <div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0"> |
| <img |
| src={LOGO_MAP[client.db_type] ?? ""} |
| alt={client.db_type} |
| className="w-5 h-5 object-contain" |
| /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-medium text-slate-800 truncate">{client.name}</p> |
| <p className="text-xs text-slate-400 capitalize">{client.db_type} Β· {client.status}</p> |
| </div> |
| <div className="flex items-center gap-1.5 flex-shrink-0"> |
| <button |
| onClick={() => handleIngest(client.id)} |
| disabled={ingesting === client.id || client.status === "inactive"} |
| title="Ingest schema to knowledge base" |
| className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 hover:bg-orange-100 hover:text-[#FF8F00] text-slate-500 transition disabled:opacity-40 disabled:cursor-not-allowed" |
| > |
| {ingesting === client.id ? ( |
| <Loader2 className="w-3 h-3 animate-spin" /> |
| ) : ( |
| <Database className="w-3 h-3" /> |
| )} |
| {ingesting === client.id ? "Ingestingβ¦" : "Ingest"} |
| </button> |
| <button |
| onClick={() => handleDeleteClient(client.id)} |
| disabled={deletingClient === client.id} |
| className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30" |
| title="Delete connection" |
| > |
| {deletingClient === client.id ? ( |
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> |
| ) : ( |
| <Trash2 className="w-3.5 h-3.5" /> |
| )} |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Documents list */} |
| <div> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> |
| Documents Β· {documents.length} |
| </span> |
| {documents.length > 0 && ( |
| <button |
| onClick={deleteAllDocuments} |
| className="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition" |
| > |
| <Trash2 className="w-3 h-3" /> |
| Clear all |
| </button> |
| )} |
| </div> |
| |
| {loadingDocs ? ( |
| <div className="flex justify-center py-10"> |
| <Loader2 className="w-5 h-5 animate-spin text-slate-300" /> |
| </div> |
| ) : docsError ? ( |
| <p className="text-center text-xs text-red-500 py-6">{docsError}</p> |
| ) : documents.length === 0 ? ( |
| <div className="text-center py-10"> |
| <div className="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center mx-auto mb-3"> |
| <FileText className="w-5 h-5 text-slate-300" /> |
| </div> |
| <p className="text-sm text-slate-400">No documents yet</p> |
| <p className="text-xs text-slate-300 mt-0.5">Upload files to get started</p> |
| </div> |
| ) : ( |
| <div className="space-y-1"> |
| {documents.map((doc) => ( |
| <div |
| key={doc.id} |
| className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group" |
| > |
| <div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0"> |
| <FileText className="w-4 h-4 text-red-400" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-medium text-slate-800 truncate" title={doc.filename}> |
| {doc.filename} |
| </p> |
| <p className="text-xs text-slate-400"> |
| {formatFileSize(doc.file_size)} Β· {formatDate(doc.created_at)} |
| </p> |
| </div> |
| <div className="flex items-center gap-2 flex-shrink-0"> |
| {renderStatus(doc)} |
| <button |
| onClick={() => handleDeleteDocument(doc.id)} |
| disabled={deleting === doc.id} |
| className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30" |
| title="Delete" |
| > |
| {deleting === doc.id ? ( |
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> |
| ) : ( |
| <Trash2 className="w-3.5 h-3.5" /> |
| )} |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| |
| {/* ββ VIEW: db-select ββ */} |
| {view === "db-select" && ( |
| <div className="space-y-4"> |
| <p className="text-sm text-slate-400"> |
| Choose a database to connect to your knowledge base. |
| </p> |
| {loadingDbTypes ? ( |
| <div className="flex justify-center py-10"> |
| <Loader2 className="w-5 h-5 animate-spin text-slate-300" /> |
| </div> |
| ) : ( |
| <div className="grid grid-cols-3 gap-2"> |
| {dbTypeInfos.map((info) => { |
| const active = info.status === "active"; |
| return ( |
| <button |
| key={info.db_type} |
| disabled={!active} |
| onClick={() => { |
| if (!active) return; |
| setSelectedDbType(info.db_type); |
| const defaults = Object.fromEntries( |
| info.fields.map((f) => [ |
| f.name, |
| f.default != null |
| ? f.default |
| : f.type === "integer" ? 0 : f.type === "boolean" ? false : "", |
| ]) |
| ); |
| setDbForm(defaults); |
| setView("db-credentials"); |
| }} |
| className={[ |
| "relative flex flex-col items-center gap-2 p-4 rounded-xl border transition text-xs font-medium", |
| active |
| ? "border-slate-200 hover:border-[#FF8F00] hover:bg-orange-50/50 text-slate-700 cursor-pointer" |
| : "border-slate-100 bg-slate-50/60 text-slate-400 cursor-not-allowed", |
| ].join(" ")} |
| > |
| <img |
| src={LOGO_MAP[info.logo] ?? ""} |
| alt={info.display_name} |
| className={`w-8 h-8 object-contain ${!active ? "opacity-30 grayscale" : ""}`} |
| /> |
| <span>{info.display_name}</span> |
| {!active && ( |
| <span className="absolute top-1.5 right-1.5 text-[9px] bg-slate-200 text-slate-400 px-1.5 py-0.5 rounded-full"> |
| Soon |
| </span> |
| )} |
| </button> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* ββ VIEW: db-credentials ββ */} |
| {view === "db-credentials" && ( |
| <div className="space-y-4"> |
| |
| {/* Selected DB chip */} |
| {selectedDbInfo && ( |
| <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100"> |
| <img src={LOGO_MAP[selectedDbInfo.logo] ?? ""} alt={selectedDbInfo.display_name} className="w-5 h-5 object-contain" /> |
| <span className="text-sm font-medium text-slate-700">{selectedDbInfo.display_name}</span> |
| </div> |
| )} |
| |
| <div className="grid grid-cols-2 gap-3"> |
| {/* Connection Name */} |
| <div className="col-span-2"> |
| <label className="block text-xs font-medium text-slate-500 mb-1"> |
| Connection Name <span className="text-red-400">*</span> |
| </label> |
| <input |
| type="text" |
| placeholder="Production DB" |
| value={connectionName} |
| onChange={(e) => setConnectionName(e.target.value)} |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition" |
| /> |
| </div> |
| |
| {/* Dynamic fields from API */} |
| {selectedDbInfo?.fields.map((field) => ( |
| <div |
| key={field.name} |
| className={field.name === "host" || field.name === "service_account_json" ? "col-span-2" : ""} |
| > |
| <label className="block text-xs font-medium text-slate-500 mb-1 capitalize"> |
| {field.name.replace(/_/g, " ")} |
| {field.required && <span className="text-red-400 ml-0.5">*</span>} |
| </label> |
| |
| {field.type === "select" ? ( |
| <select |
| value={String(dbForm[field.name] ?? field.default ?? "")} |
| onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))} |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] transition" |
| > |
| {field.options?.map((opt) => ( |
| <option key={opt} value={opt}>{opt}</option> |
| ))} |
| </select> |
| ) : field.type === "boolean" ? ( |
| <div className="flex items-center gap-2 pt-1"> |
| <input |
| type="checkbox" |
| id={`field-${field.name}`} |
| checked={Boolean(dbForm[field.name] ?? field.default ?? false)} |
| onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.checked }))} |
| className="w-4 h-4 accent-[#FF8F00]" |
| /> |
| <label htmlFor={`field-${field.name}`} className="text-xs text-slate-500"> |
| {field.description} |
| </label> |
| </div> |
| ) : field.name === "service_account_json" ? ( |
| <textarea |
| rows={4} |
| placeholder={field.description} |
| value={String(dbForm[field.name] ?? "")} |
| onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))} |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-xs bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition font-mono" |
| /> |
| ) : ( |
| <input |
| type={field.sensitive ? "password" : field.type === "integer" ? "number" : "text"} |
| placeholder={field.default != null ? String(field.default) : field.description} |
| value={String(dbForm[field.name] ?? "")} |
| onChange={(e) => |
| setDbForm((f) => ({ |
| ...f, |
| [field.name]: field.type === "integer" |
| ? (parseInt(e.target.value, 10) || 0) |
| : e.target.value, |
| })) |
| } |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition" |
| /> |
| )} |
| </div> |
| ))} |
| </div> |
| |
| <button |
| onClick={handleDbConnect} |
| disabled={ |
| connecting || |
| !connectionName.trim() || |
| (selectedDbInfo?.fields.filter((f) => f.required).some((f) => !dbForm[f.name]) ?? false) |
| } |
| className="w-full flex items-center justify-center gap-2 bg-[#FF8F00] hover:bg-[#FF6F00] active:bg-[#E65100] text-white py-2.5 rounded-xl text-sm font-medium transition disabled:opacity-40 disabled:cursor-not-allowed" |
| > |
| {connecting ? ( |
| <><Loader2 className="w-4 h-4 animate-spin" /> Connectingβ¦</> |
| ) : ( |
| "Connect" |
| )} |
| </button> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| <div className="px-5 py-3 border-t border-slate-100"> |
| <p className="text-[10px] text-slate-300 text-center"> |
| {view === "main" |
| ? `Supported formats: ${supportedFormatsText}` |
| : view === "db-select" |
| ? "More integrations coming soon" |
| : "Credentials are encrypted at rest"} |
| </p> |
| </div> |
|
|
| </div> |
| </div> |
| ); |
| } |
|
|