380 lines
22 KiB
TypeScript
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' }
|