feat(web/ui): refactor component inline styles into utility classes
Pass 2 of the web UI spacing/typography/visual-rhythm rework. - Add layout, spacing, typography, visual, card, and component utilities - Add form-section, form-row, form-help, text-error, alert-success - Unify .form-group and .form-field; add .status-badge family - Alias legacy button classes to .btn primitives - Refactor ToolTypeListSidebar and ConfigProfileListSidebar to use .sidebar and var(--sidebar-width) instead of hardcoded 280px - Refactor ToolTypeEditorPanel, ConfigProfileEditorPanel, git-mount-editor, and manifest-editor to use utility classes Quality gates: npm run typecheck, npm run lint, npm run build pass. Inline style blocks in target components reduced from 198 to 11.
This commit is contained in:
@@ -86,24 +86,24 @@ export const ConfigProfileEditorPanel = ({
|
||||
const hasSelection = isCreating || selectedProfile;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||
<div className="flex-1 overflow-auto p-5 min-w-0">
|
||||
{!hasSelection ? (
|
||||
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">
|
||||
<Icon name="folder" size="lg" />
|
||||
</div>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
|
||||
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
|
||||
<h3>Select a config profile</h3>
|
||||
<p>Choose a profile from the list to edit, or create a new one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div className="max-w-form">
|
||||
<div className="row justify-between items-start mb-5">
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||
<h1 className="m-0 mb-2 text-xl">
|
||||
{isCreating ? "Create Profile" : selectedProfile?.name}
|
||||
</h1>
|
||||
{!isCreating && selectedProfile && (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<p className="muted m-0">
|
||||
{selectedProfile.project_id &&
|
||||
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
|
||||
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
|
||||
@@ -113,8 +113,8 @@ export const ConfigProfileEditorPanel = ({
|
||||
)}
|
||||
</div>
|
||||
{!isCreating && selectedProfile && (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button className="secondary-button" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||
<div className="row row-sm">
|
||||
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||
{previewingId === selectedProfile.id ? (
|
||||
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||
) : (
|
||||
@@ -125,16 +125,16 @@ export const ConfigProfileEditorPanel = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error" style={{ marginBottom: "1rem" }}>{error}</div>}
|
||||
{error && <div className="form-error mb-3">{error}</div>}
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<div className="alert alert-success mb-3">
|
||||
<Icon name="success" size="sm" />
|
||||
Profile saved successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
|
||||
<form onSubmit={onSubmit} className="stack stack-lg">
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name *</label>
|
||||
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
|
||||
@@ -145,8 +145,8 @@ export const ConfigProfileEditorPanel = ({
|
||||
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-project">Project</label>
|
||||
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
|
||||
<option value="">None (Global)</option>
|
||||
@@ -155,7 +155,7 @@ export const ConfigProfileEditorPanel = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-tool">Tool Type</label>
|
||||
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
|
||||
<option value="">None</option>
|
||||
@@ -174,14 +174,14 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Includes</h4>
|
||||
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
|
||||
<div className="row justify-between items-center mb-3">
|
||||
<h4 className="m-0">Includes</h4>
|
||||
<span className="muted text-sm">{includedProfileIds.length} included</span>
|
||||
</div>
|
||||
{includedProfileIds.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
|
||||
<p className="muted text-sm m-0 mb-3">No profiles included. Add profiles to compose configurations.</p>
|
||||
) : (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<div className="mb-3">
|
||||
{includedProfileIds.map((profileId, index) => {
|
||||
const profile = getIncludedProfile(profileId);
|
||||
if (!profile) return null;
|
||||
@@ -193,19 +193,21 @@ export const ConfigProfileEditorPanel = ({
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
|
||||
className={dragOverIndex === index ? "drag-item drag-item-active" : "drag-item"}
|
||||
>
|
||||
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
|
||||
<span className="drag-handle"><Icon name="drag" size="sm" /></span>
|
||||
<span className="flex-1 font-medium truncate">{profile.name}</span>
|
||||
<span className="badge">{getScopeLabel(profile)}</span>
|
||||
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
|
||||
<button type="button" onClick={() => onRemoveInclude(index)} className="delete-btn" title="Remove include">
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableProfiles.length > 0 && (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div className="form-group mb-0">
|
||||
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
|
||||
<option value="">+ Add Include...</option>
|
||||
{availableProfiles.map((p) => (
|
||||
@@ -217,11 +219,11 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||
<h4 className="m-0 mb-3">Environment Variables</h4>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
|
||||
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={idx} className="form-row row-sm mb-2">
|
||||
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" />
|
||||
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveEnvVar(key)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
))}
|
||||
@@ -229,47 +231,47 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
|
||||
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<h4 className="m-0 mb-3">Runtime Hints</h4>
|
||||
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
||||
<h4 className="m-0 mb-2">Files</h4>
|
||||
<p className="muted text-sm m-0 mb-3">Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
||||
{Object.entries(formData.files || {}).map(([path, content], idx) => (
|
||||
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={idx} className="card-sm mb-2">
|
||||
<div className="form-row row-sm mb-2">
|
||||
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveFile(path)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input font-mono text-sm" />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
||||
<h4 className="m-0 mb-2">Mounts</h4>
|
||||
<p className="muted text-sm m-0 mb-3">Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
||||
{(formData.mounts || []).map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={index} className="card-sm mb-3">
|
||||
<div className="form-row row-sm mb-3">
|
||||
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" />
|
||||
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
|
||||
<option value="rw">Read/Write</option>
|
||||
<option value="ro">Read-Only</option>
|
||||
</select>
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveMount(index)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
<div style={{ marginLeft: "1rem" }}>
|
||||
<div className="pl-4 border-l-2 stack stack-sm">
|
||||
{Object.entries(mount.files).map(([path, content], idx) => (
|
||||
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
|
||||
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" />
|
||||
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input font-mono text-sm" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveMountFile(index, path)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)} style={{ fontSize: "0.875rem" }}><Icon name="add" size="sm" /> Add File to Mount</button>
|
||||
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)}><Icon name="add" size="sm" /> Add File to Mount</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -280,21 +282,21 @@ export const ConfigProfileEditorPanel = ({
|
||||
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||
<button type="submit" disabled={saveStatus === "saving"}>
|
||||
<div className="dialog-actions sticky-footer">
|
||||
<button type="submit" disabled={saveStatus === "saving"} className="btn btn-primary">
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
|
||||
</button>
|
||||
{(isCreating || saveStatus !== "idle") && (
|
||||
<button type="button" onClick={onReset} className="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||
<button type="button" onClick={onReset} className="btn btn-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{previewData && (
|
||||
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
|
||||
<div className="card stack stack-md mt-6">
|
||||
<h3>Resolved Profile Preview</h3>
|
||||
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<pre className="code-block" style={{ maxHeight: "400px" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,88 +17,38 @@ export const ConfigProfileListSidebar = ({
|
||||
onDelete,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Config Profiles</h2>
|
||||
<p className="muted">
|
||||
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||
<div className="sidebar-list">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => onSelect(profile)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
|
||||
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedProfileId !== profile.id) {
|
||||
e.currentTarget.style.background = "#ece7df";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedProfileId !== profile.id) {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}
|
||||
}}
|
||||
className={
|
||||
selectedProfileId === profile.id
|
||||
? "sidebar-item sidebar-item-active"
|
||||
: "sidebar-item"
|
||||
}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">
|
||||
{profile.name}
|
||||
{profile.is_default && (
|
||||
<span className="badge badge-secondary" style={{ marginLeft: "0.5rem" }}>
|
||||
default
|
||||
</span>
|
||||
<span className="badge badge-secondary ml-2">default</span>
|
||||
)}
|
||||
{profile.includes?.length > 0 && (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
background:
|
||||
selectedProfileId === profile.id
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="badge ml-2">
|
||||
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
|
||||
>
|
||||
<div className="sidebar-item-subtitle">
|
||||
{profile.project_id && "Project scoped"}
|
||||
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
|
||||
{!profile.project_id && !profile.tool_type_id && "Global"}
|
||||
@@ -109,16 +59,6 @@ export const ConfigProfileListSidebar = ({
|
||||
e.stopPropagation();
|
||||
onDelete(profile.id);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
className="delete-btn"
|
||||
title="Delete profile"
|
||||
>
|
||||
@@ -128,33 +68,8 @@ export const ConfigProfileListSidebar = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "2px dashed var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--brand)";
|
||||
e.currentTarget.style.color = "var(--brand)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={onCreate} className="sidebar-create-button">
|
||||
<Icon name="add" size="sm" /> New Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -70,26 +70,15 @@ export const GitMountEditor = ({
|
||||
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
|
||||
>
|
||||
<h4 className="m-0 mb-2">Git Mounts</h4>
|
||||
<p className="muted text-sm mb-3 m-0">
|
||||
Clone a repository once and mount multiple directories from it.
|
||||
</p>
|
||||
|
||||
{normalizedMounts.length > 0 && (
|
||||
<div
|
||||
className="git-mount-list"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<div className="git-mount-list mb-4">
|
||||
{normalizedMounts.map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||
<div key={index} className="card-md">
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
@@ -98,59 +87,28 @@ export const GitMountEditor = ({
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<div className="row justify-between items-start mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-base mb-1">
|
||||
{mount.remote_url}
|
||||
{mount.branch && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontWeight: 400,
|
||||
marginLeft: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span className="muted font-normal ml-2">
|
||||
@{mount.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<div className="stack stack-sm">
|
||||
{mount.mappings?.map((m, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
className="text-sm muted font-mono"
|
||||
>
|
||||
{m.source_path || "."} → {m.target_path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
|
||||
>
|
||||
<div className="row row-sm flex-none">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
@@ -177,7 +135,7 @@ export const GitMountEditor = ({
|
||||
)}
|
||||
|
||||
{isAdding ? (
|
||||
<div className="card" style={{ padding: "1rem" }}>
|
||||
<div className="card-md">
|
||||
<GitMountForm
|
||||
mount={{
|
||||
remote_url: "",
|
||||
@@ -359,16 +317,11 @@ const GitMountForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Repository URL
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<div className="stack stack-md">
|
||||
<div className="form-row row-sm items-start">
|
||||
<div className="form-group flex-2">
|
||||
<label className="text-sm font-medium">Repository URL</label>
|
||||
<div className="row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
@@ -385,7 +338,6 @@ const GitMountForm = ({
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -401,7 +353,7 @@ const GitMountForm = ({
|
||||
</button>
|
||||
</div>
|
||||
{errors.remote_url && (
|
||||
<span className="error-text">{errors.remote_url}</span>
|
||||
<span className="text-error">{errors.remote_url}</span>
|
||||
)}
|
||||
{validation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
@@ -409,7 +361,7 @@ const GitMountForm = ({
|
||||
{
|
||||
(validation as Extract<ValidationState, { status: "valid" }>)
|
||||
.branches.length
|
||||
}{" "}
|
||||
}{" "}
|
||||
branches)
|
||||
</span>
|
||||
)}
|
||||
@@ -434,10 +386,8 @@ const GitMountForm = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Branch
|
||||
</label>
|
||||
<div className="form-group flex-1">
|
||||
<label className="text-sm font-medium">Branch</label>
|
||||
{validation.status === "valid" ? (
|
||||
<select
|
||||
value={branch}
|
||||
@@ -466,18 +416,14 @@ const GitMountForm = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="stack stack-sm"
|
||||
style={{
|
||||
opacity: isUrlValidated ? 1 : 0.5,
|
||||
pointerEvents: isUrlValidated ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Mappings
|
||||
</label>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||
>
|
||||
<label className="text-sm font-medium">Mappings</label>
|
||||
<p className="muted text-sm m-0">
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
{!isUrlValidated && (
|
||||
<span style={{ color: "var(--warning)" }}>
|
||||
@@ -486,14 +432,11 @@ const GitMountForm = ({
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
>
|
||||
<div className="stack stack-sm">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
className="form-row row-sm items-start"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
@@ -503,17 +446,8 @@ const GitMountForm = ({
|
||||
}
|
||||
placeholder="packages/api"
|
||||
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
padding: "0.5rem 0",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm muted py-2">→</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.target_path}
|
||||
@@ -522,7 +456,6 @@ const GitMountForm = ({
|
||||
}
|
||||
placeholder="/app/api"
|
||||
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{mappings.length > 1 && (
|
||||
<button
|
||||
@@ -535,12 +468,12 @@ const GitMountForm = ({
|
||||
</button>
|
||||
)}
|
||||
{errors[`mapping_${index}_source`] && (
|
||||
<span className="error-text">
|
||||
<span className="text-error">
|
||||
{errors[`mapping_${index}_source`]}
|
||||
</span>
|
||||
)}
|
||||
{errors[`mapping_${index}_target`] && (
|
||||
<span className="error-text">
|
||||
<span className="text-error">
|
||||
{errors[`mapping_${index}_target`]}
|
||||
</span>
|
||||
)}
|
||||
@@ -551,17 +484,13 @@ const GitMountForm = ({
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={addMapping}
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="form-actions"
|
||||
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||
>
|
||||
<div className="form-actions row-sm mt-2">
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
Save
|
||||
</button>
|
||||
|
||||
@@ -53,35 +53,25 @@ export const ToolTypeEditorPanel = ({
|
||||
const hasSelection = isCreating || selectedToolType;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||
<div className="flex-1 overflow-auto p-5 min-w-0">
|
||||
{!hasSelection ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
paddingTop: "4rem",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">
|
||||
<Icon name="code" size="lg" />
|
||||
</div>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
|
||||
Select a tool type
|
||||
</h3>
|
||||
<p style={{ margin: 0 }}>
|
||||
Choose a tool from the list to edit, or create a new one.
|
||||
</p>
|
||||
<h3>Select a tool type</h3>
|
||||
<p>Choose a tool from the list to edit, or create a new one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||
<div className="max-w-form">
|
||||
<div className="mb-5">
|
||||
<h1 className="m-0 mb-2 text-xl">
|
||||
{isCreating
|
||||
? "Create Tool Type"
|
||||
: selectedToolType?.display_name}
|
||||
</h1>
|
||||
{!isCreating && (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<p className="muted m-0">
|
||||
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
|
||||
·{" "}
|
||||
{selectedToolType?.interface_type === "web"
|
||||
@@ -91,11 +81,7 @@ export const ToolTypeEditorPanel = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="stack"
|
||||
style={{ gap: "1rem", maxWidth: "800px" }}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="stack stack-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="definition-type">Definition Type</label>
|
||||
<select
|
||||
@@ -118,8 +104,8 @@ export const ToolTypeEditorPanel = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-name">Name *</label>
|
||||
<input
|
||||
id="tool-type-name"
|
||||
@@ -134,7 +120,7 @@ export const ToolTypeEditorPanel = ({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-display-name">
|
||||
Display Name *
|
||||
</label>
|
||||
@@ -166,8 +152,8 @@ export const ToolTypeEditorPanel = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-category">Category</label>
|
||||
<input
|
||||
id="tool-type-category"
|
||||
@@ -180,7 +166,7 @@ export const ToolTypeEditorPanel = ({
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-interface">Interface Type</label>
|
||||
<select
|
||||
id="tool-type-interface"
|
||||
@@ -280,11 +266,7 @@ export const ToolTypeEditorPanel = ({
|
||||
? "version: '3.8'\nservices:\n app:\n image: ..."
|
||||
: "FROM node:18\nWORKDIR /app\n..."
|
||||
}
|
||||
className="form-input"
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
className="form-input font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -306,8 +288,8 @@ export const ToolTypeEditorPanel = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||
<input
|
||||
id="readiness-timeout"
|
||||
@@ -319,7 +301,7 @@ export const ToolTypeEditorPanel = ({
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="readiness-interval">
|
||||
Interval (seconds)
|
||||
</label>
|
||||
@@ -350,8 +332,8 @@ export const ToolTypeEditorPanel = ({
|
||||
|
||||
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
|
||||
<button type="submit">
|
||||
<div className="dialog-actions mt-4">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
{isCreating ? "Create Tool Type" : "Save Changes"}
|
||||
</button>
|
||||
@@ -359,7 +341,7 @@ export const ToolTypeEditorPanel = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="button-secondary"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Discard
|
||||
</button>
|
||||
|
||||
@@ -17,82 +17,28 @@ export const ToolTypeListSidebar = ({
|
||||
onDelete,
|
||||
}: ToolTypeListSidebarProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
|
||||
>
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Tool Workshop</h2>
|
||||
<p className="muted">
|
||||
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||
<div className="sidebar-list">
|
||||
{toolTypes.map((toolType) => (
|
||||
<button
|
||||
key={toolType.id}
|
||||
onClick={() => onSelect(toolType)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
background:
|
||||
selectedToolTypeId === toolType.id
|
||||
? "var(--brand)"
|
||||
: "transparent",
|
||||
color:
|
||||
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedToolTypeId !== toolType.id) {
|
||||
e.currentTarget.style.background = "#ece7df";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedToolTypeId !== toolType.id) {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}
|
||||
}}
|
||||
className={
|
||||
selectedToolTypeId === toolType.id
|
||||
? "sidebar-item sidebar-item-active"
|
||||
: "sidebar-item"
|
||||
}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{toolType.display_name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8125rem",
|
||||
opacity: 0.8,
|
||||
marginTop: "0.125rem",
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{toolType.display_name}</div>
|
||||
<div className="sidebar-item-subtitle">
|
||||
{toolType.category || "Uncategorized"} ·{" "}
|
||||
{toolType.interface_type === "web"
|
||||
? `Port ${toolType.default_port}`
|
||||
@@ -104,19 +50,6 @@ export const ToolTypeListSidebar = ({
|
||||
e.stopPropagation();
|
||||
onDelete(toolType.id);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color:
|
||||
selectedToolTypeId === toolType.id
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
className="delete-btn"
|
||||
title="Delete tool type"
|
||||
>
|
||||
@@ -126,33 +59,8 @@ export const ToolTypeListSidebar = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "2px dashed var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--brand)";
|
||||
e.currentTarget.style.color = "var(--brand)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={onCreate} className="sidebar-create-button">
|
||||
<Icon name="add" size="sm" /> New Tool Type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -284,12 +284,12 @@ export const ManifestEditor = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "1.5rem" }}>
|
||||
<div className="stack stack-lg">
|
||||
{/* Base Image */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Base Image</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Base Image</h4>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label>Base Definition</label>
|
||||
<select
|
||||
value={baseDefinitionId}
|
||||
@@ -307,7 +307,7 @@ export const ManifestEditor = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label>Custom Base Image</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -325,8 +325,8 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Packages</h4>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Packages</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Node.js Version</label>
|
||||
@@ -342,9 +342,9 @@ export const ManifestEditor = ({
|
||||
|
||||
<div>
|
||||
<label>APT Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<div className="stack stack-sm mt-2">
|
||||
{aptPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
@@ -365,8 +365,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAptPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add APT Package
|
||||
</button>
|
||||
@@ -375,9 +374,9 @@ export const ManifestEditor = ({
|
||||
|
||||
<div>
|
||||
<label>NPM Global Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<div className="stack stack-sm mt-2">
|
||||
{npmPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
@@ -398,8 +397,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addNpmPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add NPM Package
|
||||
</button>
|
||||
@@ -408,9 +406,9 @@ export const ManifestEditor = ({
|
||||
|
||||
<div>
|
||||
<label>Pip Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<div className="stack stack-sm mt-2">
|
||||
{pipPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
@@ -431,8 +429,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPipPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Pip Package
|
||||
</button>
|
||||
@@ -441,10 +438,10 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Runtime User */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Runtime User</h4>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label>User Name</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -453,7 +450,7 @@ export const ManifestEditor = ({
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label>UID</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -462,7 +459,7 @@ export const ManifestEditor = ({
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label>GID</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -475,11 +472,11 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Environment Variables</h4>
|
||||
<div className="stack stack-sm">
|
||||
{envVars.map((ev, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={ev.key}
|
||||
@@ -507,8 +504,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Env Var
|
||||
</button>
|
||||
@@ -516,26 +512,20 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Build Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Build Scripts (run during docker build)</h4>
|
||||
<div className="stack stack-sm">
|
||||
{buildScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
className="form-row row-sm items-start"
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
||||
placeholder="git config --global user.email 'dev@example.com'"
|
||||
className="form-input"
|
||||
className="form-input font-mono text-sm flex-1"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -550,8 +540,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBuildScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Build Script
|
||||
</button>
|
||||
@@ -559,28 +548,22 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Startup Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">
|
||||
Startup Scripts (run when container starts)
|
||||
</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
<div className="stack stack-sm">
|
||||
{startupScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
className="form-row row-sm items-start"
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
||||
placeholder="chown -R user:user /workspace"
|
||||
className="form-input"
|
||||
className="form-input font-mono text-sm flex-1"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -595,8 +578,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStartupScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Startup Script
|
||||
</button>
|
||||
@@ -604,21 +586,15 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Mounts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Mount Schema</h4>
|
||||
<div className="stack stack-md">
|
||||
{mounts.map((mount, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="stack"
|
||||
style={{
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
className="stack stack-sm p-3 border rounded-md"
|
||||
>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<div className="form-row row-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={mount.name}
|
||||
@@ -655,15 +631,8 @@ export const ManifestEditor = ({
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<div className="form-row row-sm flex-wrap">
|
||||
<label className="checkbox-label text-sm min-h-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.writable}
|
||||
@@ -673,14 +642,7 @@ export const ManifestEditor = ({
|
||||
/>
|
||||
Writable
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<label className="checkbox-label text-sm min-h-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.readonly}
|
||||
@@ -734,8 +696,7 @@ export const ManifestEditor = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMount}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
className="button-secondary w-fit"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Mount
|
||||
</button>
|
||||
@@ -743,10 +704,10 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Runtime */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="card-md stack stack-md">
|
||||
<h4 className="m-0">Runtime</h4>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label>Command</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -756,7 +717,7 @@ export const ManifestEditor = ({
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label>Working Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -767,10 +728,8 @@ export const ManifestEditor = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<div className="form-row row-md">
|
||||
<label className="checkbox-label min-h-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stdinOpen}
|
||||
@@ -778,9 +737,7 @@ export const ManifestEditor = ({
|
||||
/>
|
||||
stdin_open
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<label className="checkbox-label min-h-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tty}
|
||||
@@ -792,15 +749,9 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
||||
<div className="card-md stack stack-md">
|
||||
<div className="row justify-between items-center">
|
||||
<h4 className="m-0">Live Preview</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
@@ -813,40 +764,16 @@ export const ManifestEditor = ({
|
||||
</div>
|
||||
{previewError && <p className="text-error">{previewError}</p>}
|
||||
{preview && (
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<div className="stack stack-md">
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Dockerfile
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
<label className="font-semibold text-sm">Dockerfile</label>
|
||||
<pre className="code-block" style={{ maxHeight: "300px" }}>
|
||||
{preview.dockerfile}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Compose
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
>
|
||||
<label className="font-semibold text-sm">Compose</label>
|
||||
<pre className="code-block" style={{ maxHeight: "200px" }}>
|
||||
{preview.compose}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
+181
-24
@@ -428,21 +428,71 @@
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
.form-field,
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-field:last-child,
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea,
|
||||
.form-field select {
|
||||
padding: 0.55rem 0.7rem;
|
||||
.form-field select,
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form-row .form-field,
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.form-section > h4:first-child,
|
||||
.form-section > h3:first-child {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
@@ -494,19 +544,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
padding: 0.75rem;
|
||||
@@ -1205,7 +1242,11 @@ a .icon:last-child {
|
||||
}
|
||||
}
|
||||
/* ─── Primitive Buttons ─── */
|
||||
.btn {
|
||||
.btn,
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button,
|
||||
.button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1224,29 +1265,41 @@ a .icon:last-child {
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
.btn:focus-visible,
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible,
|
||||
.ghost-button:focus-visible,
|
||||
.button-secondary:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.btn-primary,
|
||||
.primary-button {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
.btn-primary:hover,
|
||||
.primary-button:hover {
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
.btn-secondary,
|
||||
.secondary-button,
|
||||
.ghost-button,
|
||||
.button-secondary {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
.btn-secondary:hover,
|
||||
.secondary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.button-secondary:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -1411,6 +1464,12 @@ a .icon:last-child {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-light);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ─── Loading State ─── */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
@@ -1454,6 +1513,104 @@ a .icon:last-child {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ─── Status Badges ─── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
line-height: var(--line-height-tight);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-badge--running,
|
||||
.status-badge--success {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
}
|
||||
|
||||
.status-badge--error,
|
||||
.status-badge--failed {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.status-badge--warning {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.status-badge--info,
|
||||
.status-badge--pending,
|
||||
.status-badge--idle {
|
||||
background: var(--info-light);
|
||||
color: var(--info);
|
||||
border-color: color-mix(in srgb, var(--info) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Legacy instance-chip alias */
|
||||
.instance-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
line-height: var(--line-height-tight);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.instance-chip::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.instance-chip.running {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
}
|
||||
|
||||
.instance-chip.starting,
|
||||
.instance-chip.building,
|
||||
.instance-chip.probing {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.instance-chip.error,
|
||||
.instance-chip.unhealthy,
|
||||
.instance-chip.stopped {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Focus States ─── */
|
||||
.nav-item:focus-visible,
|
||||
.mobile-nav-item:focus-visible,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stack-sm {
|
||||
@@ -21,20 +21,103 @@
|
||||
}
|
||||
|
||||
.stack-md {
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stack-lg {
|
||||
gap: var(--space-6);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.stack-xl {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-sm {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.row-md {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.row-lg {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.row.items-start,
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row.items-center,
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row.items-end,
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flex-2 {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.flex-none {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.min-h-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.max-w-prose {
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.max-w-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -48,6 +131,286 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Visual Utilities */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* Spacing Utilities */
|
||||
.m-0 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.mt-5 {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.mr-3 {
|
||||
margin-right: var(--space-3);
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.p-5 {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: var(--space-2);
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: var(--space-3);
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: var(--space-4);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: var(--space-1);
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
padding-top: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
.pl-3 {
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.pr-2 {
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.pr-3 {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.pr-4 {
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
/* Border Utilities */
|
||||
.border {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.border-l {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.border-l-2 {
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
/* Card Utilities */
|
||||
.card-sm {
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-md {
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-lg {
|
||||
padding: var(--space-5);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.truncate-multiline {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -3777,6 +4140,209 @@ a:active,
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ─── Sticky Footer ─── */
|
||||
.sticky-footer {
|
||||
position: sticky;
|
||||
bottom: var(--space-4);
|
||||
z-index: 10;
|
||||
background: var(--panel);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ─── Drag Items ─── */
|
||||
.drag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-1);
|
||||
cursor: grab;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.drag-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.drag-item-active {
|
||||
background: var(--brand-light, color-mix(in srgb, var(--brand) 10%, transparent));
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ─── Code Block ─── */
|
||||
.code-block {
|
||||
background: var(--code-bg, #1e1e1e);
|
||||
color: var(--code-fg, #d4d4d4);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: auto;
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: var(--space-10);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
opacity: 0.3;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.sidebar-header p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sidebar-item-active,
|
||||
.sidebar-item-active:hover {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-item-active .delete-btn {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.sidebar-item-active .delete-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-item-active .badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-item-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
opacity: 0.8;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar-item .delete-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-item:hover .delete-btn,
|
||||
.sidebar-item:focus-within .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-create-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-create-button:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Mobile FAB ─── */
|
||||
.mobile-fab {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user