380 lines
22 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react'
import {
X, Plus, Trash2, Database, Globe, FileJson, Play, ChevronRight,
CheckCircle2, XCircle, Loader2, Eye, EyeOff, RefreshCw,
} from 'lucide-react'
import type { DataSource, DataCacheEntry } from '../App'
const EMPTY: DataSource = {
id: '', name: '', type: 'rest', url: '', method: 'GET',
headers: '{}', body: '', staticData: '[\n {\n "id": 1,\n "title": "Example item",\n "description": "A sample data entry"\n }\n]',
path: '',
}
function getFieldsFromData(data: Record<string, unknown>[]): string[] {
if (!data.length) return []
return Object.keys(data[0])
}
function resolvePath(obj: unknown, path: string): unknown {
if (!path.trim()) return obj
return path.split('.').reduce((acc: unknown, key) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
return undefined
}, obj)
}
async function fetchSource(source: DataSource): Promise<{ data: Record<string, unknown>[]; error: string | null }> {
if (source.type === 'static') {
try {
const parsed = JSON.parse(source.staticData || '[]')
const arr = Array.isArray(parsed) ? parsed : [parsed]
return { data: arr as Record<string, unknown>[], error: null }
} catch {
return { data: [], error: 'Invalid JSON' }
}
}
try {
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' }
try { Object.assign(hdrs, JSON.parse(source.headers || '{}')) } catch {}
const res = await fetch(source.url, {
method: source.method,
headers: hdrs,
body: source.method === 'POST' && source.body ? source.body : undefined,
})
if (!res.ok) return { data: [], error: `HTTP ${res.status} ${res.statusText}` }
const json = await res.json()
const resolved = source.path ? resolvePath(json, source.path) : json
const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : [])
return { data: arr as Record<string, unknown>[], error: null }
} catch (e) {
return { data: [], error: String(e) }
}
}
// ── Example presets ────────────────────────────────────────────
const PRESETS = [
{
label: 'JSONPlaceholder Posts',
source: { ...EMPTY, name: 'Blog Posts', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/posts', method: 'GET' as const, path: '' },
},
{
label: 'JSONPlaceholder Users',
source: { ...EMPTY, name: 'Users', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/users', method: 'GET' as const, path: '' },
},
{
label: 'Open Library Search',
source: { ...EMPTY, name: 'Books', type: 'rest' as const, url: 'https://openlibrary.org/search.json?q=javascript&limit=10', method: 'GET' as const, path: 'docs' },
},
{
label: 'Static JSON',
source: { ...EMPTY, name: 'My Data', type: 'static' as const },
},
]
export function DataSourceManager({
dataSources,
dataCache,
onSave,
onDelete,
onRefresh,
onClose,
}: {
dataSources: DataSource[]
dataCache: Record<string, DataCacheEntry>
onSave: (source: DataSource) => void
onDelete: (id: string) => void
onRefresh: (id: string) => void
onClose: () => void
}) {
const [editing, setEditing] = useState<DataSource | null>(null)
const [testResult, setTestResult] = useState<{ data: Record<string, unknown>[]; error: string | null } | null>(null)
const [testing, setTesting] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const startNew = (preset?: DataSource) => {
const id = `ds-${Date.now()}`
setEditing({ ...(preset ?? EMPTY), id })
setTestResult(null)
setShowPreview(false)
}
const handleTest = useCallback(async () => {
if (!editing) return
setTesting(true)
setTestResult(null)
const result = await fetchSource(editing)
setTestResult(result)
setTesting(false)
setShowPreview(true)
}, [editing])
const handleSave = () => {
if (!editing || !editing.name.trim()) return
onSave(editing)
setEditing(null)
setTestResult(null)
}
const fields = testResult?.data ? getFieldsFromData(testResult.data) : []
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'stretch', background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' }}
onClick={onClose}
>
<div
style={{ display: 'flex', width: '100%', height: '100%', maxWidth: 940, margin: 'auto', background: '#fff', borderRadius: '16px', overflow: 'hidden', boxShadow: '0 24px 80px rgba(0,0,0,0.25)', maxHeight: '88vh' }}
onClick={e => e.stopPropagation()}
>
{/* Source list */}
<div style={{ width: 260, flexShrink: 0, borderRight: '1px solid #f1f5f9', display: 'flex', flexDirection: 'column', background: '#fafafa' }}>
<div style={{ padding: '1rem', borderBottom: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<p style={{ margin: 0, fontSize: '0.8rem', fontWeight: 700, color: '#0f172a' }}>Data Sources</p>
<p style={{ margin: '2px 0 0', fontSize: '0.7rem', color: '#94a3b8' }}>{dataSources.length} configured</p>
</div>
<button onClick={() => startNew()} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.3rem 0.625rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}>
<Plus size={11} />New
</button>
</div>
{/* Presets */}
{dataSources.length === 0 && !editing && (
<div style={{ padding: '0.75rem', borderBottom: '1px solid #f1f5f9' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.7rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Quick start</p>
{PRESETS.map(p => (
<button key={p.label} onClick={() => startNew(p.source)}
style={{ width: '100%', textAlign: 'left', padding: '0.4rem 0.5rem', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.75rem', color: '#475569', borderRadius: '5px', display: 'flex', alignItems: 'center', gap: '0.375rem' }}
onMouseEnter={e => (e.currentTarget.style.background = '#f1f5f9')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{p.source.type === 'static' ? <FileJson size={11} /> : <Globe size={11} />}
{p.label}
</button>
))}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
{dataSources.length === 0 && (
<div style={{ padding: '1.5rem 0.5rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.75rem' }}>
<Database size={24} style={{ opacity: 0.3, marginBottom: '0.5rem' }} />
<p style={{ margin: 0 }}>No data sources yet</p>
</div>
)}
{dataSources.map(source => {
const cache = dataCache[source.id]
const isEditing = editing?.id === source.id
return (
<div
key={source.id}
onClick={() => { setEditing({ ...source }); setTestResult(null); setShowPreview(false) }}
style={{
padding: '0.625rem 0.75rem', borderRadius: '8px', cursor: 'pointer',
border: isEditing ? '2px solid #6366f1' : '2px solid transparent',
background: isEditing ? '#f5f3ff' : 'transparent', marginBottom: '2px',
transition: 'all 0.1s',
}}
onMouseEnter={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = '#f1f5f9' }}
onMouseLeave={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = 'transparent' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '2px' }}>
{source.type === 'static' ? <FileJson size={11} style={{ color: '#6366f1' }} /> : <Globe size={11} style={{ color: '#0ea5e9' }} />}
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#0f172a', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{source.name}</span>
{cache?.loading && <Loader2 size={10} style={{ color: '#6366f1', animation: 'spin 1s linear infinite' }} />}
{!cache?.loading && cache?.error && <XCircle size={10} style={{ color: '#ef4444' }} />}
{!cache?.loading && !cache?.error && cache?.data?.length > 0 && <CheckCircle2 size={10} style={{ color: '#22c55e' }} />}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>
{source.type === 'static' ? 'Static' : `${source.method} · ${source.url.split('/').slice(0, 4).join('/')}`}
</span>
{cache?.data && <span style={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 600 }}>{cache.data.length} items</span>}
</div>
</div>
)
})}
</div>
</div>
{/* Edit pane */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1.25rem', borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0f172a' }}>
{editing ? (editing.name || 'New data source') : 'Data Sources'}
</span>
<button onClick={onClose} style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '7px', cursor: 'pointer', color: '#64748b' }}>
<X size={14} />
</button>
</div>
{editing ? (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Form */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid #f1f5f9' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>Name</label>
<input style={inp} value={editing.name} onChange={e => setEditing(s => s ? { ...s, name: e.target.value } : s)} placeholder="My API" />
</div>
<div>
<label style={lbl}>Type</label>
<select style={inp} value={editing.type} onChange={e => setEditing(s => s ? { ...s, type: e.target.value as 'rest' | 'static' } : s)}>
<option value="rest">REST API</option>
<option value="static">Static JSON</option>
</select>
</div>
{editing.type === 'rest' && (
<div>
<label style={lbl}>Method</label>
<select style={inp} value={editing.method} onChange={e => setEditing(s => s ? { ...s, method: e.target.value as 'GET' | 'POST' } : s)}>
<option>GET</option>
<option>POST</option>
</select>
</div>
)}
{editing.type === 'rest' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>URL</label>
<input style={inp} value={editing.url} onChange={e => setEditing(s => s ? { ...s, url: e.target.value } : s)} placeholder="https://api.example.com/items" />
</div>
)}
{editing.type === 'rest' && (
<div>
<label style={lbl}>Response path <span style={{ color: '#94a3b8' }}>(optional)</span></label>
<input style={inp} value={editing.path} onChange={e => setEditing(s => s ? { ...s, path: e.target.value } : s)} placeholder="data.results" />
</div>
)}
{editing.type === 'rest' && (
<div>
<label style={lbl}>Headers <span style={{ color: '#94a3b8' }}>(JSON)</span></label>
<input style={inp} value={editing.headers} onChange={e => setEditing(s => s ? { ...s, headers: e.target.value } : s)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
)}
{editing.type === 'rest' && editing.method === 'POST' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>Request body</label>
<textarea style={{ ...inp, height: 64, resize: 'none' as const }} value={editing.body} onChange={e => setEditing(s => s ? { ...s, body: e.target.value } : s)} placeholder='{"query": "..."}' />
</div>
)}
{editing.type === 'static' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>JSON data <span style={{ color: '#94a3b8' }}>(array)</span></label>
<textarea
style={{ ...inp, height: 160, resize: 'vertical' as const, fontFamily: 'monospace', fontSize: '0.75rem' }}
value={editing.staticData}
onChange={e => setEditing(s => s ? { ...s, staticData: e.target.value } : s)}
/>
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.875rem', flexWrap: 'wrap' }}>
<button onClick={handleTest} disabled={testing} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#f1f5f9', border: '1px solid #e2e8f0', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, color: '#475569' }}>
{testing ? <Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> : <Play size={12} />}
Test
</button>
<button onClick={handleSave} disabled={!editing.name.trim()} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, opacity: editing.name.trim() ? 1 : 0.5 }}>
Save source
</button>
{dataSources.find(s => s.id === editing.id) && (
confirmDelete === editing.id ? (
<>
<span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'flex', alignItems: 'center' }}>Delete?</span>
<button onClick={() => { onDelete(editing.id); setEditing(null); setConfirmDelete(null) }} style={{ padding: '0.4rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600 }}>Yes</button>
<button onClick={() => setConfirmDelete(null)} style={{ padding: '0.4rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>No</button>
</>
) : (
<button onClick={() => setConfirmDelete(editing.id)} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.4rem 0.625rem', background: 'transparent', border: '1px solid #fecaca', color: '#ef4444', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>
<Trash2 size={11} />Delete
</button>
)
)}
<button onClick={() => { setEditing(null); setTestResult(null) }} style={{ marginLeft: 'auto', padding: '0.4rem 0.625rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.75rem' }}>Cancel</button>
</div>
</div>
{/* Test result */}
{testResult && (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 1.25rem', borderBottom: '1px solid #f1f5f9', background: testResult.error ? '#fef2f2' : '#f0fdf4', flexShrink: 0 }}>
{testResult.error
? <><XCircle size={14} style={{ color: '#ef4444' }} /><span style={{ fontSize: '0.75rem', color: '#dc2626', fontWeight: 600 }}>Error: {testResult.error}</span></>
: <><CheckCircle2 size={14} style={{ color: '#22c55e' }} /><span style={{ fontSize: '0.75rem', color: '#15803d', fontWeight: 600 }}>{testResult.data.length} items returned</span>
{fields.length > 0 && <span style={{ fontSize: '0.7rem', color: '#64748b', marginLeft: '0.5rem' }}>Fields: {fields.join(', ')}</span>}
</>
}
{!testResult.error && (
<button onClick={() => setShowPreview(p => !p)} style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.2rem 0.5rem', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '5px', cursor: 'pointer', fontSize: '0.7rem', color: '#64748b' }}>
{showPreview ? <EyeOff size={10} /> : <Eye size={10} />}{showPreview ? 'Hide' : 'Preview'}
</button>
)}
</div>
{showPreview && !testResult.error && testResult.data.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.72rem' }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{fields.slice(0, 8).map(f => (
<th key={f} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 700, color: '#475569', borderBottom: '1px solid #f1f5f9', whiteSpace: 'nowrap' }}>{f}</th>
))}
</tr>
</thead>
<tbody>
{testResult.data.slice(0, 8).map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid #f8fafc' }}>
{fields.slice(0, 8).map(f => (
<td key={f} style={{ padding: '0.4rem 0.75rem', color: '#334155', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{String(row[f] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
{testResult.data.length > 8 && <p style={{ padding: '0.5rem 0.75rem', margin: 0, fontSize: '0.7rem', color: '#94a3b8' }}>… and {testResult.data.length - 8} more rows</p>}
</div>
)}
</div>
)}
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', gap: '0.5rem' }}>
<Database size={36} style={{ opacity: 0.25 }} />
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 500 }}>Select a source to edit</p>
<p style={{ margin: 0, fontSize: '0.8rem' }}>or <button onClick={() => startNew()} style={{ background: 'none', border: 'none', color: '#6366f1', cursor: 'pointer', fontWeight: 600, padding: 0, fontSize: '0.8rem' }}>create a new one</button></p>
</div>
)}
{/* Live cache overview when no editing */}
{!editing && dataSources.length > 0 && (
<div style={{ padding: '0.75rem 1.25rem', borderTop: '1px solid #f1f5f9', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{dataSources.map(s => {
const cache = dataCache[s.id]
return (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.625rem', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '6px', fontSize: '0.72rem', color: '#475569' }}>
<span style={{ fontWeight: 600 }}>{s.name}</span>
{cache?.loading && <Loader2 size={9} style={{ color: '#6366f1' }} />}
{!cache?.loading && cache?.error && <span style={{ color: '#ef4444' }}>error</span>}
{!cache?.loading && !cache?.error && <span style={{ color: '#22c55e' }}>{cache?.data?.length ?? 0} items</span>}
<button onClick={() => onRefresh(s.id)} title="Refresh" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: '#94a3b8' }}>
<RefreshCw size={9} />
</button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
const lbl: React.CSSProperties = { display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#475569', marginBottom: '0.25rem' }
const inp: React.CSSProperties = { width: '100%', fontSize: '0.78rem', padding: '0.4rem 0.625rem', border: '1px solid #e2e8f0', borderRadius: '6px', outline: 'none', background: '#fff', color: '#0f172a', boxSizing: 'border-box', fontFamily: 'inherit' }