/* =================================================================== KabelFlux Behemoth — Main App Bundle-design port. Tweaks panel + design-iteration UI dropped. =================================================================== */ /* Minimal local replacement for the bundle's useTweaks hook (which spoke to Claude Design via postMessage). Persists to localStorage so the user's accent / density / etc. survive a reload. */ function useTweaks(defaults) { const [values, setValues] = React.useState(() => { try { const saved = JSON.parse(localStorage.getItem('kfx_tweaks') || '{}'); return Object.assign({}, defaults, saved); } catch (e) { return defaults; } }); const setTweak = React.useCallback((keyOrEdits, val) => { const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { [keyOrEdits]: val }; setValues((prev) => { const next = Object.assign({}, prev, edits); try { localStorage.setItem('kfx_tweaks', JSON.stringify(next)); } catch (e) {} return next; }); }, []); return [values, setTweak]; } const TABS = [ { group: 'Design', items: [ { k: 'layout', l: 'Layout', icon: 'layout' }, { k: 'wires', l: 'Wires', icon: 'wires' }, { k: 'connectors', l: 'Connectors', icon: 'connector' }, { k: 'nodes', l: 'Nodes', icon: 'nodes' }, ]}, { group: 'Documentation', items: [ { k: 'bom', l: 'BOM', icon: 'bom' }, { k: 'notes', l: 'Notes & Revisions', icon: 'notes' }, ]}, { group: 'Project', items: [ { k: 'projectsettings', l: 'Parameters', icon: 'tune' }, ]}, ]; /* ----- Project switcher dropdown ----- Single topbar control: shows the current project (or "Select project") and on click opens a menu with: · Projects home (collection page) · Create new project · — divider — · Other projects in the workspace */ const ProjectSwitcher = ({ projects, currentId, onOpenProject, onGoToProjects, onCreateNew }) => { const [open, setOpen] = useState(false); const ref = useRef(null); const current = projects.find(p => p.id === currentId); const others = projects.filter(p => p.id !== currentId); // Click-outside + Esc to close. useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [open]); return (
{open && (
Switch to
{others.length === 0 && (
No other projects in this workspace.
)} {others.map(p => ( ))}
)}
); }; /* =================================================================== Boot states — shown before App renders the main shell. - BootSplash: brief while kfxBootstrap() is in flight - LoginScreen: shown when no valid session - ErrorScreen: shown when the backend errors out (network down etc.) =================================================================== */ const BootSplash = () => (
Loading workspace…
); const ErrorScreen = ({ err, onRetry }) => (

Can't reach the server

{(err && err.message) || 'The KabelFlux API didn\'t respond.'}

Retry
); const LoginScreen = ({ onLogin }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const submit = async (e) => { e && e.preventDefault(); if (!username.trim() || !password) { setErr('Enter username and password.'); return; } setBusy(true); setErr(''); try { await onLogin(username.trim(), password); } catch (ex) { setErr(ex && ex.status === 401 ? 'Invalid username or password.' : (ex && ex.message) || 'Could not sign in.'); setBusy(false); } }; return (
KabelFlux
e-Harness Suite

Sign in

{err &&
{err}
} {busy ? 'Signing in…' : 'Sign in'}
Register company →
); }; const App = () => { const [t, setT] = useTweaks(window.TWEAK_DEFAULTS); /* Auth bootstrap state — see kfxBootstrap() in data.jsx. */ const [bootKind, setBootKind] = useState('loading'); const [me, setMe] = useState(null); const [projects, setProjects] = useState([]); const [bootErr, setBootErr] = useState(null); const [tab, setTab] = useState('projects'); const [pid, setPid] = useState(null); const [projectDetail, setProjectDetail] = useState(null); const [projectLoading, setProjectLoading] = useState(false); const [filter, setFilter] = useState(''); const [paletteOpen, setPaletteOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [showBackups, setShowBackups] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); const [showUsers, setShowUsers] = useState(false); const [showCompanies, setShowCompanies] = useState(false); const [showShareLinks, setShowShareLinks] = useState(false); const [showSysHealth, setShowSysHealth] = useState(false); const [showWorkspace, setShowWorkspace] = useState(false); const [wireMode, setWireMode] = useState(false); // showFocusDemo removed — design-iteration overlay, not a real app feature const [adminMenuOpen, setAdminMenuOpen] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false); const project = useMemo(() => projects.find(p => p.id === pid), [projects, pid]); const toast = useToast(); /* Run kfxBootstrap on mount. Result decides whether we render the login screen, the error screen, or the main shell with live data. */ useEffect(() => { let cancelled = false; (async () => { const res = await kfxBootstrap(); if (cancelled) return; if (res.kind === 'authed') { setMe(res.me); setProjects(res.projects); setBootKind('authed'); } else if (res.kind === 'guest') { setBootKind('guest'); } else { setBootErr(res.err); setBootKind('error'); } })(); return () => { cancelled = true; }; }, []); /* Load per-project detail (wires, connectors, nodes) whenever the selected project changes. `pid` is the bundle's 'p' id; we look up the matching project row to get the backend's realId. */ const realPid = useMemo(() => { const proj = projects.find(p => p.id === pid); return proj && proj.realId ? proj.realId : null; }, [pid, projects]); const refreshProject = useCallback(async () => { if (!realPid) return; try { const detail = await kfxLoadAndMapProject(realPid); setProjectDetail(detail); } catch (e) { toast.err('Could not refresh project', (e && e.message) || 'Unknown error'); } }, [realPid, toast]); useEffect(() => { if (!pid) { setProjectDetail(null); return; } if (!realPid) { setProjectDetail(null); return; } let cancelled = false; setProjectLoading(true); (async () => { try { const detail = await kfxLoadAndMapProject(realPid); if (!cancelled) setProjectDetail(detail); } catch (e) { if (!cancelled) { setProjectDetail({ wires: [], connectors: [], nodes: [], bundles: [], raw: null, error: e }); toast.err('Could not load project', (e && e.message) || 'Unknown error'); } } finally { if (!cancelled) setProjectLoading(false); } })(); return () => { cancelled = true; }; }, [pid, realPid]); /* ⌘K + global shortcuts */ useEffect(() => { const fn = e => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setPaletteOpen(true); return; } if (e.key === 'Escape') { if (wireMode) { setWireMode(false); } } if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) { e.preventDefault(); const el = document.querySelector('.kf-search input'); if (el) el.focus(); } if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); setSidebarCollapsed(s => !s); } if (e.key.toLowerCase() === 'w' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName) && !e.metaKey && !e.ctrlKey) { setWireMode(w => !w); } }; document.addEventListener('keydown', fn); return () => document.removeEventListener('keydown', fn); }, [wireMode]); /* density + accent + theme driven by tweaks */ useEffect(() => { const root = document.documentElement; if (t.density === 'compact') { root.style.setProperty('--density-row', '26px'); } if (t.density === 'cozy') { root.style.setProperty('--density-row', '32px'); } if (t.density === 'comfortable') { root.style.setProperty('--density-row', '40px'); } // Theme palettes recolour the whole shell via data-theme on . // Each theme picks a sensible default accent so the swatches the user sees // match what's actually applied; the user can still override below. const prevTheme = root.getAttribute('data-theme'); const nextTheme = t.theme || 'midnight'; if (prevTheme !== nextTheme) { // Suppress `transition: all` on themed elements while colors swap — // Chrome holds the old variable-resolved color mid-transition otherwise. root.classList.add('kf-theme-switching'); requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('kf-theme-switching'))); } root.setAttribute('data-theme', nextTheme); // Other tweakable design surfaces — each maps to a data-* attribute the // stylesheet selects on. Defaults match the original look so missing keys // (older saved tweaks) are a no-op. root.setAttribute('data-type', t.typeface || 'plex'); root.setAttribute('data-corners', t.corners || 'soft'); root.setAttribute('data-nav-active', t.navActive || 'tint'); root.setAttribute('data-elev', t.elevation || 'flat'); const accentMap = { midnight: { red:'#ef4e4e', blue:'#4a9eff', teal:'#00d4aa' }, graphite: { red:'#f06a3a', blue:'#6fa8ff', teal:'#2dd4a7' }, daylight: { red:'#d63838', blue:'#2563eb', teal:'#009972' }, }; const softMap = { midnight: { red:'rgba(239,78,78,.15)', blue:'rgba(74,158,255,.15)', teal:'rgba(0,212,170,.15)' }, graphite: { red:'rgba(240,106,58,.16)', blue:'rgba(111,168,255,.16)', teal:'rgba(45,212,167,.16)' }, daylight: { red:'rgba(214,56,56,.10)', blue:'rgba(37,99,235,.10)', teal:'rgba(0,153,114,.12)' }, }; const themeKey = (t.theme && accentMap[t.theme]) ? t.theme : 'midnight'; const accent = (t.accent && accentMap[themeKey][t.accent]) ? t.accent : 'red'; root.style.setProperty('--accent', accentMap[themeKey][accent]); root.style.setProperty('--accent-soft', softMap[themeKey][accent]); }, [t.density, t.accent, t.theme, t.typeface, t.corners, t.navActive, t.elevation]); /* Hidden file input for CSV import — triggered by Import buttons. */ const csvImportInputRef = useRef(null); const triggerCsvImport = useCallback(() => { if (!realPid) { toast.warn('Open a project first'); return; } if (csvImportInputRef.current) csvImportInputRef.current.click(); }, [realPid, toast]); const handleCsvImportFile = useCallback(async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f || !realPid) return; try { const res = await kfxImportCsv(realPid, f); toast.ok('CSV imported', (res && res.message) || f.name); await refreshProject(); } catch (ex) { toast.err('Import failed', (ex && ex.message) || 'Unknown error'); } }, [realPid, refreshProject, toast]); /* New-project modal state — wired below. */ const [showNewProject, setShowNewProject] = useState(false); const reloadProjects = useCallback(async () => { try { const ps = await kfxLoadProjects(); setProjects(ps); return ps; } catch (e) { toast.err('Could not reload projects', (e && e.message) || 'Unknown error'); return projects; } }, [toast, projects]); /* Export action dispatcher. Names match backend /api/projects/{pid}/export-X. */ const EXPORT_KIND_TO_NAME = { prod: 'combined', cont: 'continuity', 'cont-xlsx': 'continuity-xlsx', komax: 'komax', as50881: 'as50881', dxf: 'dxf', 'bom-xlsx': 'bom-xlsx', bom: 'bom', 'wiretable': 'wiretable', 'wiretable-xlsx': 'wiretable-xlsx', schematic: 'pdf', 'all-zip': 'all-zip', csv: 'csv', kfx: 'kfx', 'combined-xlsx': 'combined-xlsx', }; const doExport = useCallback((kind) => { if (!realPid) { toast.warn('Open a project first'); return; } const name = EXPORT_KIND_TO_NAME[kind] || kind; try { kfxDownload(realPid, name); toast.ok('Export started', `Downloading ${name}…`); } catch (e) { toast.err('Export failed', (e && e.message) || 'Unknown error'); } }, [realPid, toast]); /* palette → action */ const onNav = (kind) => { const [type, value] = kind.split(':'); if (type === 'goto') setTab(value); if (type === 'project') { setPid(value); setTab('home'); } if (type === 'action') { if (value === 'wire-mode') { setWireMode(true); setTab('layout'); } if (value === 'new-wire') { setTab('wires'); } if (value === 'new-conn') { setTab('connectors'); } if (value === 'new-node') { setTab('nodes'); } if (value === 'import') triggerCsvImport(); } if (type === 'export') { doExport(value); } if (type === 'admin') { if (!isAdmin) { toast.warn('Admin role required'); return; } // SYSTEM items (super-admin only): backups / health / companies const superOnly = new Set(['backups', 'health', 'companies']); if (superOnly.has(value) && !isSuper) { toast.warn('Super-admin role required'); return; } if (value === 'backups') setShowBackups(true); else if (value === 'users') setShowUsers(true); else if (value === 'share') setShowShareLinks(true); else if (value === 'workspace') setShowWorkspace(true); else if (value === 'companies') setShowCompanies(true); else if (value === 'health') setShowSysHealth(true); else if (value === 'audit') setShowAuditLog(true); else toast.info(`Admin · ${value}`); } }; const filteredProjects = projects.filter(p => !filter || (p.name + ' ' + p.code).toLowerCase().includes(filter.toLowerCase())); /* Logout: kfxLogout clears storage, then we flip back to the login screen. */ const handleLogout = async () => { await kfxLogout(); setMe(null); setProjects([]); setPid(null); setTab('projects'); setUserMenuOpen(false); setBootKind('guest'); }; /* Render gates. Auth bootstrap runs once on mount; until it lands we show a splash; on `guest` we render the login screen; on `error` an error screen with a retry hint. */ if (bootKind === 'loading') return ; if (bootKind === 'guest') return ( { const user = await kfxLogin(u, p); setMe(user); const ps = await kfxLoadProjects(); setProjects(ps); setBootKind('authed'); }}/> ); if (bootKind === 'error') return window.location.reload()}/>; /* userInitials sourced from the live `me` object — falls back gracefully if neither full_name nor username is present. */ const userInitials = (() => { const src = (me && (me.full_name || me.username)) || ''; const parts = src.trim().split(/\s+/).filter(Boolean); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return '??'; })(); const userDisplayName = (me && (me.full_name || me.username)) || 'User'; const userRoleLine = me ? `${me.role || 'designer'}${me.company_name ? ' · ' + me.company_name : ''}` : ''; const isAdmin = !!(me && (me.role === 'admin' || me.role === 'superadmin')); const isSuper = !!(me && me.role === 'superadmin'); return (
{/* TOPBAR */}
KabelFlux
e-Harness Suite
{/* Workspace switcher — single dropdown. First item routes to the Projects collection page, second item creates a new project, then a list of the rest. */} { setPid(id); setTab('home'); }} onGoToProjects={() => setTab('projects')} onCreateNew={() => setShowNewProject(true)} />
{/* Command palette trigger */} {/* Symbol library — workspace-level, not tied to a single project. */} setTab('library')}/> {/* Advanced (was: Admin) — dropdown for workspace ops + platform admin. Hidden for non-admins; superadmin sees the extra "Companies" item. */} {isAdmin && (
setAdminMenuOpen(o => !o)}/> {adminMenuOpen && ( setAdminMenuOpen(false)} width={260}> {/* OPERATIONS — admin + super-admin */} Operations { setShowWorkspace(true); setAdminMenuOpen(false); }}/> { setShowUsers(true); setAdminMenuOpen(false); }}/> { setShowShareLinks(true); setAdminMenuOpen(false); }}/> { setShowAuditLog(true); setAdminMenuOpen(false); }}/> {/* SYSTEM — super-admin only (cross-tenant + platform-level) Tenant admins shouldn't see Companies (cross-tenant view), Database backups (platform storage), or System health (platform diagnostics). */} {isSuper && ( System { setShowCompanies(true); setAdminMenuOpen(false); }}/> { setShowBackups(true); setAdminMenuOpen(false); }}/> { setShowSysHealth(true); setAdminMenuOpen(false); }}/> )} )}
)} {/* Export */}
setExportMenuOpen(o => !o)}>Export {exportMenuOpen && ( setExportMenuOpen(false)} width={300}> Recommended { doExport('prod'); setExportMenuOpen(false); }}/> Manufacturing & QA { doExport('cont'); setExportMenuOpen(false); }}/> { doExport('cont-xlsx'); setExportMenuOpen(false); }}/> { doExport('komax'); setExportMenuOpen(false); }}/> { doExport('as50881'); setExportMenuOpen(false); }}/> { doExport('dxf'); setExportMenuOpen(false); }}/> Tables { doExport('wiretable'); setExportMenuOpen(false); }}/> { doExport('wiretable-xlsx'); setExportMenuOpen(false); }}/> { doExport('bom'); setExportMenuOpen(false); }}/> { doExport('bom-xlsx'); setExportMenuOpen(false); }}/> { doExport('combined-xlsx'); setExportMenuOpen(false); }}/> Other { doExport('schematic'); setExportMenuOpen(false); }}/> { doExport('csv'); setExportMenuOpen(false); }}/> { doExport('kfx'); setExportMenuOpen(false); }}/> { doExport('all-zip'); setExportMenuOpen(false); }}/> )}
{/* User */}
{userMenuOpen && ( setUserMenuOpen(false)} width={220}> { setTab('settings'); setUserMenuOpen(false); }}/> { setTab('settings'); setUserMenuOpen(false); }}/> { setTab('settings'); setUserMenuOpen(false); }}/> )}
{/* SIDEBAR */} {/* MAIN */}
{!project && tab !== 'projects' && tab !== 'settings' && tab !== 'library' && setTab('projects')} onImportCsv={triggerCsvImport}/>} {tab === 'projects' && { setPid(id); setTab('home'); }} onCreateNew={() => setShowNewProject(true)} onImportCsv={triggerCsvImport}/>} {project && tab === 'home' && } {project && tab === 'layout' && } {project && tab === 'wires' && } {project && tab === 'connectors' && } {project && tab === 'bom' && } {project && tab === 'nodes' && } {project && tab === 'notes' && } {tab === 'library' && } {tab === 'settings' && } {project && tab === 'projectsettings' && } {/* Admin slide-overs — role-gated. Designers/viewers can't open them even via deep link. Superadmin-only panels (Companies) get a stricter check. */} {showBackups && isSuper && setShowBackups(false)}/>} {showAuditLog && isAdmin && setShowAuditLog(false)}/>} {showUsers && isAdmin && setShowUsers(false)} me={me}/>} {showCompanies && isSuper && setShowCompanies(false)}/>} {showShareLinks && isAdmin && setShowShareLinks(false)} realPid={realPid} projectName={project ? project.name : ''}/>} {showSysHealth && isSuper && setShowSysHealth(false)}/>} {showWorkspace && isAdmin && setShowWorkspace(false)} me={me}/>} {/* New project modal */} {showNewProject && ( setShowNewProject(false)} onCreated={async (created) => { const ps = await reloadProjects(); setShowNewProject(false); const fresh = ps.find(p => p.realId === created.id); if (fresh) { setPid(fresh.id); setTab('home'); } toast.ok('Project created', created.name); }}/> )} {/* Hidden file input — used by every "Import CSV" button. */}
{/* STATUS BAR */}
Connected · API 0.4.32 · {project ? `${project.code} · Rev ${project.revision}` : 'No project'} · {wireMode ? 'Wire mode ON' : 'Idle'}
Autosave on · last sync 4s ago ·
{/* Palette */} setPaletteOpen(false)} onNav={onNav} project={project} projects={projects} isAdmin={isAdmin} isSuper={isSuper}/>
); }; /* ---------- Placeholder for the views we didn't fully build ---------- */ const PlaceholderView = ({ title, icon, hint, cta }) => (
Filter {cta} }/>
{cta}} secondary={Import}/>
); /* ---------- Backups slide-over — wired to /api/admin/backup* ---------- */ const BackupsPanel = ({ onClose }) => { const toast = useToast(); const [backups, setBackups] = useState([]); const [loading, setLoading] = useState(true); const [paused, setPaused] = useState(false); // backups_enabled === false const [savingPause, setSavingPause] = useState(false); const [confirm, setConfirm] = useState(null); const [importOpen, setImportOpen] = useState(false); const refresh = useCallback(async () => { try { const [list, settings] = await Promise.all([ kfxListBackups(), kfxGetBackupSettings(), ]); setBackups(Array.isArray(list) ? list : []); setPaused(!settings.backups_enabled); } catch (e) { toast.err('Could not load backups', (e && e.message) || 'Unknown error'); } finally { setLoading(false); } }, [toast]); useEffect(() => { refresh(); }, [refresh]); const togglePause = async () => { setSavingPause(true); try { const next = !paused; // next = paused? const r = await kfxSetBackupSettings({ backups_enabled: !next }); setPaused(!r.backups_enabled); toast.ok(r.backups_enabled ? 'Backups enabled' : 'Backups paused'); } catch (e) { toast.err('Could not save', (e && e.message) || 'Unknown error'); } finally { setSavingPause(false); } }; const restoreOne = (b) => setConfirm({ title: `Restore from ${b.filename}?`, body: <>Projects in this snapshot will be appended into the live database. Same-name projects get renamed (no overwrites)., confirmLabel: 'Restore', onConfirm: async () => { try { const r = await kfxRestoreBackup(b.filename); toast.ok('Restore complete', `${r.restored || 0} project(s) appended`); await refresh(); } catch (e) { toast.err('Restore failed', (e && e.message) || 'Unknown error'); } }, }); return (
e.stopPropagation()}>

Database backups

Manual snapshots + automatic 3 AM job + pre-push git hook. All restorable.

{paused && (
Automatic + manual backups are paused. Existing snapshots remain restorable.
)}
{ try { const r = await kfxTriggerBackup(); toast.ok('Snapshot created', r.filename || ''); await refresh(); } catch (e) { toast.err('Snapshot failed', (e && e.message) || 'Unknown error'); } }} busyLabel="Snapshotting…">Backup now setImportOpen(true)}> Import from legacy DB {paused ? 'Backups paused' : 'Backups ON'}
{backups.length} snapshots
{loading &&
Loading…
} {!loading && backups.length === 0 && ( )} {!loading && backups.map(b => (
{b.timestamp || b.filename}
{b.label || 'auto'}
{(b.size_kb || 0).toFixed ? b.size_kb.toFixed(1) : b.size_kb} KB
restoreOne(b)}>Restore setConfirm({ title: `Delete backup ${b.filename}?`, body: <>Deletion is permanent — no undo., confirmLabel: 'Delete backup', danger: true, onConfirm: async () => { try { await kfxDeleteBackupFile(b.filename); toast.ok('Backup deleted'); await refresh(); } catch (e) { toast.err('Delete failed', (e && e.message) || 'Unknown error'); } }, })}/>
))}
{confirm && setConfirm(null)}/>} {importOpen && ( setImportOpen(false)} onImport={async (file) => { try { const r = await kfxImportLegacyDb(file); toast.ok('Legacy DB imported', `${r.restored || 0} project(s) appended`); setImportOpen(false); await refresh(); } catch (e) { toast.err('Import failed', (e && e.message) || 'Unknown error'); } }}/> )}
); }; /* ---------- Legacy-DB import modal ---------- Wired version: picks a SQLite .db file and POSTs it to /api/admin/backup/import (multipart). Backend then runs the restore_backup_append flow. Other formats (.sql/.zip/.json) are queued for v5.1, surfaced via a hint. */ const LegacyImportModal = ({ onClose, onImport }) => { const [file, setFile] = useState(null); const [busy, setBusy] = useState(false); const onFilePick = (e) => { const f = e.target.files && e.target.files[0]; if (f) setFile(f); }; const submit = async () => { if (!file) return; setBusy(true); try { await onImport(file); } finally { setBusy(false); } }; return ( {} : onClose} footer={ <> Cancel {busy ? 'Importing…' : 'Run import'} }>
{file ? file.name : 'Pick a SQLite .db file'}
{file ? `${(file.size / 1024).toFixed(1)} KB — ready to import` : 'Backend supports SQLite (.db) today. CSV / JSON / Postgres are queued for v5.1.'}
A pre-import snapshot is taken automatically. Same-name projects get renamed (no overwrites).
); }; /* ---------- New project modal ---------- */ const NewProjectModal = ({ onClose, onCreated }) => { const [name, setName] = useState(''); const [harnessName, setHarnessName] = useState(''); const [description, setDescription] = useState(''); const toast = useToast(); const canSave = name.trim().length > 0; return ( Cancel { if (!canSave) return; try { const created = await kfxCreateProject({ name: name.trim(), harness_name: harnessName.trim() || name.trim(), description: description.trim(), }); await onCreated(created); } catch (e) { toast.err('Could not create project', (e && e.message) || 'Unknown error'); } }}>Create project }>
setName(e.target.value)} placeholder="e.g. Mira-S Tail Boom"/>
setHarnessName(e.target.value)} placeholder="e.g. HA0000168"/>