Spaces:
Running
Running
| 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) { | |
| // ── Document state ────────────────────────────────────────────────────────── | |
| 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); | |
| // ── Navigation state ──────────────────────────────────────────────────────── | |
| const [view, setView] = useState<View>("main"); | |
| const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null); | |
| // ── DB type & client state ────────────────────────────────────────────────── | |
| 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); | |
| // ── DB credentials form state ─────────────────────────────────────────────── | |
| 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 { | |
| // non-blocking; silently fail | |
| } finally { | |
| setLoadingDbTypes(false); | |
| } | |
| }; | |
| const handleClose = () => { | |
| setView("main"); | |
| setSelectedDbType(null); | |
| setConnectionName(""); | |
| setDbForm({}); | |
| onClose(); | |
| }; | |
| // ── Document handlers ─────────────────────────────────────────────────────── | |
| 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: uploadRes.data.status, | |
| 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 { | |
| // continue deleting others | |
| } | |
| } | |
| setDocuments([]); | |
| }; | |
| // ── DB handlers ───────────────────────────────────────────────────────────── | |
| 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); | |
| } | |
| }; | |
| // ── Helpers ───────────────────────────────────────────────────────────────── | |
| 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; | |
| // ── Header title & back button logic ──────────────────────────────────────── | |
| 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; | |
| // ── Render ─────────────────────────────────────────────────────────────────── | |
| 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> | |
| {/* Footer */} | |
| <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> | |
| ); | |
| } | |