Spaces:
Sleeping
Sleeping
| import { useState } from 'react' | |
| import { motion } from 'framer-motion' | |
| import { X, PlugZap, Database, CheckCircle2, XCircle, Loader2, RotateCcw } from 'lucide-react' | |
| import { useStore } from '../store/useStore' | |
| import { connectExternalDb, fetchSuggestQuestions } from '../lib/api' | |
| interface ConnectDBProps { | |
| onClose: () => void | |
| } | |
| type DbType = 'sqlite' | 'postgres' | |
| const SQLITE_EXAMPLES = [ | |
| { label: 'In-memory (blank)', value: ':memory:' }, | |
| { label: 'Custom path', value: '/path/to/your/database.db' }, | |
| ] | |
| const POSTGRES_EXAMPLES = [ | |
| { label: 'Local default', value: 'postgresql://postgres:password@localhost:5432/mydb' }, | |
| { label: 'With SSL', value: 'postgresql://user:pass@host:5432/dbname?sslmode=require' }, | |
| ] | |
| export function ConnectDB({ onClose }: ConnectDBProps) { | |
| const { dbLabel, setDbLabel, setTables, setDbSeeded, setIsCustomDb, setCustomDbSuggestions, setSuggestionsLoading } = useStore() | |
| const [dbType, setDbType] = useState<DbType>('sqlite') | |
| const [value, setValue] = useState('') | |
| const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle') | |
| const [message, setMessage] = useState('') | |
| const placeholder = dbType === 'postgres' | |
| ? 'postgresql://user:password@host:5432/dbname' | |
| : '/path/to/database.db' | |
| const examples = dbType === 'postgres' ? POSTGRES_EXAMPLES : SQLITE_EXAMPLES | |
| const getDsn = () => { | |
| if (dbType === 'postgres' && value.trim() && !value.trim().startsWith('postgresql://') && !value.trim().startsWith('postgres://')) { | |
| return `postgresql://${value.trim()}` | |
| } | |
| return value.trim() | |
| } | |
| const handleConnect = async () => { | |
| const dsn = getDsn() | |
| if (!dsn) return | |
| setStatus('connecting') | |
| setMessage('') | |
| try { | |
| const res = await connectExternalDb(dsn) | |
| if (res.success) { | |
| setDbLabel(res.dbLabel) | |
| setTables(res.tables) | |
| setDbSeeded(true) | |
| setIsCustomDb(true) | |
| setStatus('success') | |
| setMessage(res.message) | |
| // Start generating suggestions in the background (cached by DSN) | |
| setCustomDbSuggestions([]) | |
| setSuggestionsLoading(true) | |
| fetchSuggestQuestions(dsn) | |
| .then((qs) => { setCustomDbSuggestions(qs); setSuggestionsLoading(false) }) | |
| .catch(() => setSuggestionsLoading(false)) | |
| } else { | |
| setStatus('error') | |
| setMessage(res.message) | |
| } | |
| } catch (e) { | |
| setStatus('error') | |
| setMessage(e instanceof Error ? e.message : 'Connection failed') | |
| } | |
| } | |
| const handleReset = async () => { | |
| setStatus('connecting') | |
| try { | |
| const res = await connectExternalDb('/app/backend/data/benchmark.db') | |
| if (res.success) { | |
| setDbLabel(res.dbLabel) | |
| setTables(res.tables) | |
| setDbSeeded(true) | |
| setIsCustomDb(false) | |
| setCustomDbSuggestions([]) | |
| setSuggestionsLoading(false) | |
| setStatus('success') | |
| setMessage('Reset to built-in benchmark database') | |
| } else { | |
| setStatus('error') | |
| setMessage(res.message) | |
| } | |
| } catch (e) { | |
| setStatus('error') | |
| setMessage(e instanceof Error ? e.message : 'Reset failed') | |
| } | |
| } | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="fixed inset-0 z-[200] flex items-center justify-center p-4" | |
| style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }} | |
| onClick={(e) => { if (e.target === e.currentTarget) onClose() }} | |
| > | |
| <motion.div | |
| initial={{ scale: 0.95, opacity: 0, y: 8 }} | |
| animate={{ scale: 1, opacity: 1, y: 0 }} | |
| exit={{ scale: 0.95, opacity: 0 }} | |
| transition={{ duration: 0.15 }} | |
| className="w-full max-w-md rounded-2xl border shadow-2xl overflow-hidden" | |
| style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-color)' }} | |
| > | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border-color)' }}> | |
| <div className="flex items-center gap-2.5"> | |
| <div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(135deg,#1e3a5f,#2d1b69)' }}> | |
| <PlugZap size={13} className="text-white" /> | |
| </div> | |
| <div> | |
| <h2 className="text-sm font-semibold theme-text-primary">Connect Database</h2> | |
| <p className="text-[10px] text-gray-500">SQLite file or PostgreSQL connection string</p> | |
| </div> | |
| </div> | |
| <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500 hover:text-gray-300 transition-colors"> | |
| <X size={15} /> | |
| </button> | |
| </div> | |
| {/* Current DB */} | |
| <div className="px-5 py-3 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--bg-tertiary)' }}> | |
| <Database size={11} className="text-violet-400 shrink-0" /> | |
| <span className="text-[11px] text-gray-500">Active:</span> | |
| <span className="text-[11px] font-semibold text-violet-400 truncate">{dbLabel}</span> | |
| <button | |
| onClick={handleReset} | |
| className="ml-auto flex items-center gap-1 text-[10px] text-gray-600 hover:text-gray-400 transition-colors" | |
| title="Reset to built-in demo database" | |
| > | |
| <RotateCcw size={9} /> | |
| Reset to demo | |
| </button> | |
| </div> | |
| {/* Body */} | |
| <div className="px-5 py-4 flex flex-col gap-4"> | |
| {/* DB Type toggle */} | |
| <div> | |
| <label className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider block mb-1.5"> | |
| Database Type | |
| </label> | |
| <div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border-color)' }}> | |
| {(['sqlite', 'postgres'] as DbType[]).map((type) => ( | |
| <button | |
| key={type} | |
| onClick={() => { setDbType(type); setValue(''); setStatus('idle') }} | |
| className="flex-1 py-2 text-xs font-medium transition-all" | |
| style={{ | |
| background: dbType === type ? 'linear-gradient(135deg,#7c3aed,#2563eb)' : 'var(--bg-tertiary)', | |
| color: dbType === type ? '#fff' : 'var(--text-secondary)', | |
| }} | |
| > | |
| {type === 'sqlite' ? 'SQLite' : 'PostgreSQL'} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider block mb-1.5"> | |
| {dbType === 'postgres' ? 'Connection String' : 'File Path'} | |
| </label> | |
| <input | |
| type={dbType === 'postgres' ? 'text' : 'text'} | |
| value={value} | |
| onChange={(e) => { setValue(e.target.value); setStatus('idle') }} | |
| onKeyDown={(e) => e.key === 'Enter' && void handleConnect()} | |
| placeholder={placeholder} | |
| className="w-full px-3 py-2.5 text-sm rounded-xl border focus:outline-none transition-all font-mono" | |
| style={{ | |
| background: 'var(--bg-tertiary)', | |
| borderColor: 'var(--border-color)', | |
| color: 'var(--text-primary)', | |
| }} | |
| autoFocus | |
| /> | |
| {dbType === 'postgres' && ( | |
| <p className="text-[10px] text-gray-600 mt-1"> | |
| Format: <span className="font-mono text-gray-500">postgresql://user:password@host:port/dbname</span> | |
| </p> | |
| )} | |
| </div> | |
| {/* Quick examples */} | |
| <div className="flex flex-col gap-1.5"> | |
| <span className="text-[10px] text-gray-600 uppercase tracking-wider">Quick select</span> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {examples.map((ex) => ( | |
| <button | |
| key={ex.value} | |
| onClick={() => { setValue(ex.value); setStatus('idle') }} | |
| className="text-[10px] px-2.5 py-1 rounded-full border transition-all text-gray-500 hover:text-gray-300" | |
| style={{ borderColor: 'var(--border-color)', background: 'var(--bg-tertiary)' }} | |
| > | |
| {ex.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Status message */} | |
| {status !== 'idle' && ( | |
| <div className={`flex items-start gap-2 rounded-xl px-3 py-2.5 text-xs ${ | |
| status === 'success' ? 'bg-green-500/10 border border-green-500/20 text-green-400' : | |
| status === 'error' ? 'bg-red-500/10 border border-red-500/20 text-red-400' : | |
| 'bg-violet-500/10 border border-violet-500/20 text-violet-400' | |
| }`}> | |
| {status === 'connecting' && <Loader2 size={12} className="animate-spin shrink-0 mt-0.5" />} | |
| {status === 'success' && <CheckCircle2 size={12} className="shrink-0 mt-0.5" />} | |
| {status === 'error' && <XCircle size={12} className="shrink-0 mt-0.5" />} | |
| <span>{status === 'connecting' ? 'Connecting…' : message}</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="px-5 pb-5 flex items-center justify-end gap-2"> | |
| <button | |
| onClick={onClose} | |
| className="px-4 py-2 rounded-xl text-xs font-medium text-gray-500 hover:text-gray-300 transition-colors" | |
| > | |
| {status === 'success' ? 'Close' : 'Cancel'} | |
| </button> | |
| <button | |
| onClick={() => void handleConnect()} | |
| disabled={!value.trim() || status === 'connecting'} | |
| className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold text-white transition-all active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed" | |
| style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)' }} | |
| > | |
| {status === 'connecting' ? ( | |
| <><Loader2 size={11} className="animate-spin" /> Connecting…</> | |
| ) : ( | |
| <><PlugZap size={11} /> Connect</> | |
| )} | |
| </button> | |
| </div> | |
| </motion.div> | |
| </motion.div> | |
| ) | |
| } | |