Merge pull request #6 from SamuelSJames/claude/wonderful-faraday-qxym5t
Support multiple integration instances and add credential field hints
This commit is contained in:
commit
0cf53fc1f1
1 changed files with 267 additions and 131 deletions
|
|
@ -35,17 +35,40 @@ const accentColors = [
|
||||||
{ name: 'Red', color: '#E74C3C' },
|
{ name: 'Red', color: '#E74C3C' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type FieldDef = { key: string; label: string; secret?: boolean }
|
type FieldDef = { key: string; label: string; secret?: boolean; hint?: string; placeholder?: string }
|
||||||
|
|
||||||
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
|
const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean; fields: FieldDef[] }[] = [
|
||||||
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
|
{ type: 'proxmox', name: 'Proxmox', multiInstance: true, fields: [
|
||||||
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
|
{ key: 'baseUrl', label: 'Host URL', hint: 'e.g. https://192.168.1.10:8006', placeholder: 'https://192.168.1.10:8006' },
|
||||||
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
|
{
|
||||||
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
|
key: 'apiKey',
|
||||||
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
label: 'API Token',
|
||||||
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
secret: true,
|
||||||
|
hint: 'Must be the FULL token string from Datacenter → Permissions → API Tokens, in the form USER@REALM!TOKENID=SECRET — not just the secret.',
|
||||||
|
placeholder: 'root@pam!archnest=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||||
|
},
|
||||||
|
] },
|
||||||
|
{ type: 'docker', name: 'Docker', multiInstance: true, fields: [
|
||||||
|
{ key: 'baseUrl', label: 'Socket / Remote URL', hint: 'Unix socket path or remote daemon URL, e.g. unix:///var/run/docker.sock or tcp://host:2375', placeholder: 'unix:///var/run/docker.sock' },
|
||||||
|
] },
|
||||||
|
{ type: 'netbird', name: 'NetBird', fields: [
|
||||||
|
{ key: 'apiKey', label: 'API Key', secret: true, hint: 'Personal access token from NetBird dashboard → Settings → Access Tokens.' },
|
||||||
|
] },
|
||||||
|
{ type: 'cloudflare', name: 'Cloudflare', fields: [
|
||||||
|
{ key: 'apiKey', label: 'API Token', secret: true, hint: 'A scoped API token (not your Global API Key) from My Profile → API Tokens.' },
|
||||||
|
{ key: 'zoneId', label: 'Zone ID', hint: 'Found on the domain overview page in the Cloudflare dashboard.' },
|
||||||
|
] },
|
||||||
|
{ type: 'aws', name: 'AWS', multiInstance: true, fields: [
|
||||||
|
{ key: 'accessKey', label: 'Access Key ID', hint: 'IAM user access key, e.g. AKIAIOSFODNN7EXAMPLE' },
|
||||||
|
{ key: 'secretKey', label: 'Secret Access Key', secret: true, hint: 'IAM user secret key — paired with the Access Key ID above.' },
|
||||||
|
{ key: 'region', label: 'Region', hint: 'e.g. us-east-1', placeholder: 'us-east-1' },
|
||||||
|
] },
|
||||||
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [
|
||||||
|
{ key: 'baseUrl', label: 'URL', placeholder: 'https://uptime.example.com' },
|
||||||
|
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||||
|
] },
|
||||||
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
||||||
{ type: 'remote_desktop', name: 'Remote Desktop', fields: [
|
{ type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [
|
||||||
{ key: 'protocol', label: 'Protocol (rdp / vnc / telnet)' },
|
{ key: 'protocol', label: 'Protocol (rdp / vnc / telnet)' },
|
||||||
{ key: 'hostname', label: 'Hostname' },
|
{ key: 'hostname', label: 'Hostname' },
|
||||||
{ key: 'port', label: 'Port' },
|
{ key: 'port', label: 'Port' },
|
||||||
|
|
@ -651,12 +674,16 @@ function SshHostsSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NewIntegrationDraft = { id: number; type: string; values: Record<string, string> }
|
||||||
|
|
||||||
function IntegrationsSection() {
|
function IntegrationsSection() {
|
||||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||||
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
||||||
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
|
const [editDrafts, setEditDrafts] = useState<Record<number, Record<string, string>>>({})
|
||||||
|
const [newDrafts, setNewDrafts] = useState<NewIntegrationDraft[]>([])
|
||||||
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
||||||
const [busy, setBusy] = useState<Set<string>>(new Set())
|
const [busy, setBusy] = useState<Set<string>>(new Set())
|
||||||
|
const nextDraftId = useRef(-1)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||||
|
|
@ -671,65 +698,101 @@ function IntegrationsSection() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusyFlag(type: string, value: boolean) {
|
function setBusyFlag(rowKey: string, value: boolean) {
|
||||||
setBusy((prev) => {
|
setBusy((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (value) next.add(type)
|
if (value) next.add(rowKey)
|
||||||
else next.delete(type)
|
else next.delete(rowKey)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDraftField(type: string, fieldKey: string, value: string) {
|
function setEditField(integrationId: number, fieldKey: string, value: string) {
|
||||||
setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } }))
|
setEditDrafts((prev) => ({ ...prev, [integrationId]: { ...prev[integrationId], [fieldKey]: value } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
function setNewDraftField(draftId: number, fieldKey: string, value: string) {
|
||||||
setBusyFlag(def.type, true)
|
setNewDrafts((prev) => prev.map((d) => (d.id === draftId ? { ...d, values: { ...d.values, [fieldKey]: value } } : d)))
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: '' }))
|
}
|
||||||
try {
|
|
||||||
const draft = drafts[def.type] ?? {}
|
function addNewDraft(type: string) {
|
||||||
|
const id = nextDraftId.current--
|
||||||
|
setNewDrafts((prev) => [...prev, { id, type, values: {} }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNewDraft(id: number) {
|
||||||
|
setNewDrafts((prev) => prev.filter((d) => d.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record<string, string>) {
|
||||||
const config: Record<string, string> = {}
|
const config: Record<string, string> = {}
|
||||||
const secrets: Record<string, string> = {}
|
const secrets: Record<string, string> = {}
|
||||||
for (const f of def.fields) {
|
for (const f of def.fields) {
|
||||||
const value = draft[f.key]
|
const value = values[f.key]
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue
|
||||||
if (f.secret) secrets[f.key] = value
|
if (f.secret) secrets[f.key] = value
|
||||||
else config[f.key] = value
|
else config[f.key] = value
|
||||||
}
|
}
|
||||||
let integration: Integration
|
return { config, secrets }
|
||||||
if (existing) {
|
|
||||||
;({ integration } = await api.updateIntegration(existing.id, { config, secrets }))
|
|
||||||
} else {
|
|
||||||
;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }))
|
|
||||||
}
|
}
|
||||||
setIntegrations((prev) => {
|
|
||||||
const others = (prev ?? []).filter((i) => i.id !== integration.id)
|
async function handleSaveExisting(def: (typeof integrationTypeDefs)[number], existing: Integration) {
|
||||||
return [...others, integration]
|
const rowKey = `e-${existing.id}`
|
||||||
})
|
setBusyFlag(rowKey, true)
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' }))
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: '' }))
|
||||||
|
try {
|
||||||
|
const { config, secrets } = buildPayload(def, editDrafts[existing.id] ?? {})
|
||||||
|
const { integration } = await api.updateIntegration(existing.id, { config, secrets })
|
||||||
|
setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i)))
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: 'Saved' }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' }))
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' }))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyFlag(def.type, false)
|
setBusyFlag(rowKey, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
async function handleSaveNew(def: (typeof integrationTypeDefs)[number], draft: NewIntegrationDraft) {
|
||||||
if (!existing) {
|
const rowKey = `n-${draft.id}`
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' }))
|
setBusyFlag(rowKey, true)
|
||||||
return
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: '' }))
|
||||||
|
try {
|
||||||
|
const { config, secrets } = buildPayload(def, draft.values)
|
||||||
|
const { integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets })
|
||||||
|
setIntegrations((prev) => [...(prev ?? []), integration])
|
||||||
|
removeNewDraft(draft.id)
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' }))
|
||||||
|
} finally {
|
||||||
|
setBusyFlag(rowKey, false)
|
||||||
}
|
}
|
||||||
setBusyFlag(def.type, true)
|
}
|
||||||
|
|
||||||
|
async function handleTest(existing: Integration) {
|
||||||
|
const rowKey = `e-${existing.id}`
|
||||||
|
setBusyFlag(rowKey, true)
|
||||||
try {
|
try {
|
||||||
const result = await api.testIntegration(existing.id)
|
const result = await api.testIntegration(existing.id)
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: result.message }))
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: result.message }))
|
||||||
const { integrations } = await api.listIntegrations()
|
const { integrations } = await api.listIntegrations()
|
||||||
setIntegrations(integrations)
|
setIntegrations(integrations)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' }))
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Test failed' }))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyFlag(def.type, false)
|
setBusyFlag(rowKey, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(existing: Integration) {
|
||||||
|
if (!window.confirm(`Remove this ${existing.name} integration?`)) return
|
||||||
|
const rowKey = `e-${existing.id}`
|
||||||
|
setBusyFlag(rowKey, true)
|
||||||
|
try {
|
||||||
|
await api.deleteIntegration(existing.id)
|
||||||
|
setIntegrations((prev) => (prev ?? []).filter((i) => i.id !== existing.id))
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Delete failed' }))
|
||||||
|
setBusyFlag(rowKey, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,6 +804,43 @@ function IntegrationsSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFields(def: (typeof integrationTypeDefs)[number], rowKey: string, getValue: (f: FieldDef) => string, onChange: (f: FieldDef, value: string) => void, placeholderForSecret: string) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{def.fields.map((f) => {
|
||||||
|
const key = `${rowKey}-${f.key}`
|
||||||
|
const isRevealed = revealed.has(key)
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label style={labelStyle}>{f.label}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||||
|
value={getValue(f)}
|
||||||
|
onChange={(e) => onChange(f, e.target.value)}
|
||||||
|
placeholder={f.secret ? placeholderForSecret : f.placeholder ?? 'Not configured'}
|
||||||
|
/>
|
||||||
|
{f.secret && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleReveal(key)}
|
||||||
|
className="absolute cursor-pointer border-none bg-transparent"
|
||||||
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
||||||
|
>
|
||||||
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{f.hint && (
|
||||||
|
<p style={{ fontSize: '10.5px', color: '#7A7D85', marginTop: '4px', lineHeight: 1.4, maxWidth: '260px' }}>{f.hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -751,12 +851,18 @@ function IntegrationsSection() {
|
||||||
<h3 style={sectionTitle}>Other Integrations</h3>
|
<h3 style={sectionTitle}>Other Integrations</h3>
|
||||||
</div>
|
</div>
|
||||||
{integrationTypeDefs.map((def) => {
|
{integrationTypeDefs.map((def) => {
|
||||||
const existing = integrations.find((i) => i.type === def.type)
|
const existingRows = integrations.filter((i) => i.type === def.type)
|
||||||
const online = existing?.status === 'connected'
|
const draftRows = newDrafts.filter((d) => d.type === def.type)
|
||||||
const draft = drafts[def.type] ?? {}
|
const canAddAnother = def.multiInstance || existingRows.length + draftRows.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={def.type} style={cardBase}>
|
<div key={def.type} className="flex flex-col gap-3">
|
||||||
|
{existingRows.map((existing) => {
|
||||||
|
const rowKey = `e-${existing.id}`
|
||||||
|
const online = existing.status === 'connected'
|
||||||
|
const draft = editDrafts[existing.id] ?? {}
|
||||||
|
return (
|
||||||
|
<div key={existing.id} style={cardBase}>
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
|
|
@ -768,79 +874,109 @@ function IntegrationsSection() {
|
||||||
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{def.name}</span>
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{existing.name || def.name}</span>
|
||||||
|
{existing.config.baseUrl || existing.config.hostname ? (
|
||||||
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{existing.config.baseUrl || existing.config.hostname}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{statusMsg[def.type] && (
|
{statusMsg[rowKey] && (
|
||||||
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[rowKey]}</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave(def, existing)}
|
onClick={() => handleSaveExisting(def, existing)}
|
||||||
disabled={busy.has(def.type)}
|
disabled={busy.has(rowKey)}
|
||||||
className="cursor-pointer border-none"
|
className="cursor-pointer border-none"
|
||||||
style={{
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#0A0B0D',
|
|
||||||
backgroundColor: '#C8A434',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
opacity: busy.has(def.type) ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTest(def, existing)}
|
onClick={() => handleTest(existing)}
|
||||||
disabled={busy.has(def.type)}
|
disabled={busy.has(rowKey)}
|
||||||
className="cursor-pointer border-none"
|
className="cursor-pointer border-none"
|
||||||
style={{
|
style={{ fontSize: '11px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#C8A434',
|
|
||||||
backgroundColor: 'rgba(200,164,52,0.08)',
|
|
||||||
border: '1px solid rgba(200,164,52,0.2)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
opacity: busy.has(def.type) ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Test Connection
|
Test Connection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{def.fields.map((f) => {
|
|
||||||
const key = `${def.type}-${f.key}`
|
|
||||||
const isRevealed = revealed.has(key)
|
|
||||||
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
|
||||||
const value = draft[f.key] ?? savedValue
|
|
||||||
return (
|
|
||||||
<div key={key}>
|
|
||||||
<label style={labelStyle}>{f.label}</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
type={f.secret && !isRevealed ? 'password' : 'text'}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setDraftField(def.type, f.key, e.target.value)}
|
|
||||||
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
|
||||||
/>
|
|
||||||
{f.secret && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleReveal(key)}
|
onClick={() => handleDelete(existing)}
|
||||||
className="absolute cursor-pointer border-none bg-transparent"
|
disabled={busy.has(rowKey)}
|
||||||
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#E74C3C', backgroundColor: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderFields(
|
||||||
|
def,
|
||||||
|
rowKey,
|
||||||
|
(f) => draft[f.key] ?? (f.secret ? '' : existing.config[f.key] ?? ''),
|
||||||
|
(f, value) => setEditField(existing.id, f.key, value),
|
||||||
|
'••••••••••••',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{draftRows.map((draft) => {
|
||||||
|
const rowKey = `n-${draft.id}`
|
||||||
|
return (
|
||||||
|
<div key={draft.id} style={cardBase}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||||
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>New {def.name}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusMsg[rowKey] && (
|
||||||
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[rowKey]}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveNew(def, draft)}
|
||||||
|
disabled={busy.has(rowKey)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeNewDraft(draft.id)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#7A7D85', backgroundColor: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderFields(
|
||||||
|
def,
|
||||||
|
rowKey,
|
||||||
|
(f) => draft.values[f.key] ?? '',
|
||||||
|
(f, value) => setNewDraftField(draft.id, f.key, value),
|
||||||
|
'',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{existingRows.length === 0 && draftRows.length === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => addNewDraft(def.type)}
|
||||||
|
className="cursor-pointer self-start"
|
||||||
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
||||||
|
>
|
||||||
|
+ Add {def.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canAddAnother && (existingRows.length > 0 || draftRows.length > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={() => addNewDraft(def.type)}
|
||||||
|
className="cursor-pointer self-start"
|
||||||
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
||||||
|
>
|
||||||
|
+ Add Another {def.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue