feat(ui): major config form UX overhaul
Sidebar: - SVG icons instead of emoji (consistent rendering) - Clean navigation with active states Form fields completely redesigned: - Toggle rows: full-width clickable with label + description - Segmented controls: for enum values with ≤5 options - Number inputs: with +/- stepper buttons - Text inputs: with reset-to-default button - Select dropdowns: clean styling with custom arrow - Arrays: card-based with clear add/remove, item numbering - Objects: collapsible sections with chevron animation - Maps: key-value editor with inline editing Visual improvements: - Consistent border radius and spacing - Better color contrast for labels vs help text - Hover and focus states throughout - Icons for common actions (add, remove, reset) Mobile: - Horizontal scrolling nav on small screens - Stacked layouts for complex fields
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,15 @@ function jsonValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
// SVG Icons as template literals
|
||||
const icons = {
|
||||
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
|
||||
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
|
||||
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
|
||||
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
|
||||
};
|
||||
|
||||
export function renderNode(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
@@ -44,27 +53,29 @@ export function renderNode(params: {
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const key = pathKey(path);
|
||||
const hasDefault = schema.default !== undefined;
|
||||
const isDefault = hasDefault && JSON.stringify(value) === JSON.stringify(schema.default);
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
|
||||
if (unsupported.has(key)) {
|
||||
return html`<div class="callout danger">
|
||||
${label}: unsupported schema node. Use Raw.
|
||||
return html`<div class="cfg-field cfg-field--error">
|
||||
<div class="cfg-field__label">${label}</div>
|
||||
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf unions
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = schema.anyOf ?? schema.oneOf ?? [];
|
||||
const nonNull = variants.filter(
|
||||
(v) =>
|
||||
!(
|
||||
v.type === "null" ||
|
||||
(Array.isArray(v.type) && v.type.includes("null"))
|
||||
),
|
||||
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null")))
|
||||
);
|
||||
|
||||
if (nonNull.length === 1) {
|
||||
return renderNode({ ...params, schema: nonNull[0] });
|
||||
}
|
||||
|
||||
// Check if it's a set of literal values (enum-like)
|
||||
const extractLiteral = (v: JsonSchema): unknown | undefined => {
|
||||
if (v.const !== undefined) return v.const;
|
||||
if (v.enum && v.enum.length === 1) return v.enum[0];
|
||||
@@ -73,137 +84,314 @@ export function renderNode(params: {
|
||||
const literals = nonNull.map(extractLiteral);
|
||||
const allLiterals = literals.every((v) => v !== undefined);
|
||||
|
||||
if (allLiterals && literals.length > 0) {
|
||||
if (allLiterals && literals.length > 0 && literals.length <= 5) {
|
||||
// Use segmented control for small sets
|
||||
const resolvedValue = value ?? schema.default;
|
||||
const currentIndex = literals.findIndex(
|
||||
(lit) =>
|
||||
lit === resolvedValue || String(lit) === String(resolvedValue),
|
||||
);
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<select
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const idx = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
${literals.map(
|
||||
(lit, idx) =>
|
||||
html`<option value=${String(idx)}>${String(lit)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<div class="cfg-segmented">
|
||||
${literals.map((lit, idx) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, lit)}
|
||||
>
|
||||
${String(lit)}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (allLiterals && literals.length > 5) {
|
||||
// Use dropdown for larger sets
|
||||
return renderSelect({ ...params, options: literals.map(String), value: String(value ?? schema.default ?? "") });
|
||||
}
|
||||
|
||||
// Handle mixed primitive types
|
||||
const primitiveTypes = new Set(
|
||||
nonNull
|
||||
.map((variant) => schemaType(variant))
|
||||
.filter((variant): variant is string => Boolean(variant)),
|
||||
nonNull.map((variant) => schemaType(variant)).filter(Boolean)
|
||||
);
|
||||
const normalizedTypes = new Set(
|
||||
[...primitiveTypes].map((variant) => (variant === "integer" ? "number" : variant)),
|
||||
);
|
||||
const primitiveOnly = [...normalizedTypes].every((variant) =>
|
||||
["string", "number", "boolean"].includes(variant),
|
||||
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v))
|
||||
);
|
||||
|
||||
if (primitiveOnly && normalizedTypes.size > 0) {
|
||||
if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) {
|
||||
const hasString = normalizedTypes.has("string");
|
||||
const hasNumber = normalizedTypes.has("number");
|
||||
const hasBoolean = normalizedTypes.has("boolean");
|
||||
|
||||
if (hasBoolean && normalizedTypes.size === 1) {
|
||||
return renderNode({
|
||||
...params,
|
||||
schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (hasString || hasNumber) {
|
||||
const displayValue = value ?? schema.default ?? "";
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type=${hasNumber && !hasString ? "number" : "text"}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (hasString || !hasNumber || raw.trim() === "" || /[^0-9-.]/.test(raw)) {
|
||||
onPatch(path, raw === "" ? undefined : raw);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
return renderTextInput({
|
||||
...params,
|
||||
inputType: hasNumber && !hasString ? "number" : "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum - use segmented for small, dropdown for large
|
||||
if (schema.enum) {
|
||||
const options = schema.enum;
|
||||
const resolvedValue = value ?? schema.default;
|
||||
const currentIndex = options.findIndex(
|
||||
(opt) =>
|
||||
opt === resolvedValue || String(opt) === String(resolvedValue),
|
||||
);
|
||||
const unset = "__unset__";
|
||||
if (options.length <= 5) {
|
||||
const resolvedValue = value ?? schema.default;
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<div class="cfg-segmented">
|
||||
${options.map((opt) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, opt)}
|
||||
>
|
||||
${String(opt)}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return renderSelect({ ...params, options: options.map(String), value: String(value ?? schema.default ?? "") });
|
||||
}
|
||||
|
||||
// Object type - collapsible section
|
||||
if (type === "object") {
|
||||
return renderObject(params);
|
||||
}
|
||||
|
||||
// Array type
|
||||
if (type === "array") {
|
||||
return renderArray(params);
|
||||
}
|
||||
|
||||
// Boolean - toggle row
|
||||
if (type === "boolean") {
|
||||
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<select
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const idx = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, idx === unset ? undefined : options[Number(idx)]);
|
||||
}}
|
||||
>
|
||||
<option value=${unset}>Select…</option>
|
||||
${options.map(
|
||||
(opt, idx) =>
|
||||
html`<option value=${String(idx)}>${String(opt)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
|
||||
<div class="cfg-toggle-row__content">
|
||||
<span class="cfg-toggle-row__label">${label}</span>
|
||||
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
|
||||
</div>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${displayValue}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
const fallback = value ?? schema.default;
|
||||
const obj =
|
||||
fallback && typeof fallback === "object" && !Array.isArray(fallback)
|
||||
? (fallback as Record<string, unknown>)
|
||||
: {};
|
||||
const props = schema.properties ?? {};
|
||||
const entries = Object.entries(props);
|
||||
const sorted = entries.sort((a, b) => {
|
||||
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
|
||||
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
const reserved = new Set(Object.keys(props));
|
||||
const additional = schema.additionalProperties;
|
||||
const allowExtra = Boolean(additional) && typeof additional === "object";
|
||||
// Number/Integer
|
||||
if (type === "number" || type === "integer") {
|
||||
return renderNumberInput(params);
|
||||
}
|
||||
|
||||
// String
|
||||
if (type === "string") {
|
||||
return renderTextInput({ ...params, inputType: "text" });
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return html`
|
||||
<div class="cfg-field cfg-field--error">
|
||||
<div class="cfg-field__label">${label}</div>
|
||||
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTextInput(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
inputType: "text" | "number";
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
|
||||
const placeholder = hint?.placeholder ?? (schema.default !== undefined ? `Default: ${schema.default}` : "");
|
||||
const displayValue = value ?? "";
|
||||
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<div class="cfg-input-wrap">
|
||||
<input
|
||||
type=${isSensitive ? "password" : inputType}
|
||||
class="cfg-input"
|
||||
placeholder=${placeholder}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (inputType === "number" && raw.trim() !== "") {
|
||||
const parsed = Number(raw);
|
||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||
} else {
|
||||
onPatch(path, raw === "" ? undefined : raw);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
${schema.default !== undefined ? html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-input__reset"
|
||||
title="Reset to default"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, schema.default)}
|
||||
>↺</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNumberInput(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const displayValue = value ?? schema.default ?? "";
|
||||
const numValue = typeof displayValue === "number" ? displayValue : 0;
|
||||
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<div class="cfg-number">
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-number__btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, numValue - 1)}
|
||||
>−</button>
|
||||
<input
|
||||
type="number"
|
||||
class="cfg-number__input"
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
onPatch(path, parsed);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-number__btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, numValue + 1)}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSelect(params: {
|
||||
schema: JsonSchema;
|
||||
value: string;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
options: string[];
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, options, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<select
|
||||
class="cfg-select"
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, val === "" ? undefined : val);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!value}>Select...</option>
|
||||
${options.map(opt => html`
|
||||
<option value=${opt} ?selected=${opt === value}>${opt}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderObject(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchQuery?: string;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
|
||||
const fallback = value ?? schema.default;
|
||||
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
|
||||
? (fallback as Record<string, unknown>)
|
||||
: {};
|
||||
const props = schema.properties ?? {};
|
||||
const entries = Object.entries(props);
|
||||
|
||||
// Sort by hint order
|
||||
const sorted = entries.sort((a, b) => {
|
||||
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
|
||||
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
const reserved = new Set(Object.keys(props));
|
||||
const additional = schema.additionalProperties;
|
||||
const allowExtra = Boolean(additional) && typeof additional === "object";
|
||||
|
||||
// For top-level, don't wrap in collapsible
|
||||
if (path.length === 1) {
|
||||
return html`
|
||||
<div class="fieldset">
|
||||
${showLabel ? html`<div class="legend">${label}</div>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
|
||||
<div class="cfg-fields">
|
||||
${sorted.map(([propKey, node]) =>
|
||||
renderNode({
|
||||
schema: node,
|
||||
@@ -213,162 +401,151 @@ export function renderNode(params: {
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
}),
|
||||
searchQuery,
|
||||
})
|
||||
)}
|
||||
|
||||
${allowExtra
|
||||
? renderMapField({
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
})
|
||||
: nothing}
|
||||
${allowExtra ? renderMapField({
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "array") {
|
||||
const itemsSchema = Array.isArray(schema.items)
|
||||
? schema.items[0]
|
||||
: schema.items;
|
||||
if (!itemsSchema) {
|
||||
return html`<div class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
<div class="muted">Unsupported array schema. Use Raw.</div>
|
||||
</div>`;
|
||||
}
|
||||
const arr = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(schema.default)
|
||||
? schema.default
|
||||
: [];
|
||||
// Nested objects get collapsible treatment
|
||||
return html`
|
||||
<details class="cfg-object" open>
|
||||
<summary class="cfg-object__header">
|
||||
<span class="cfg-object__title">${label}</span>
|
||||
<span class="cfg-object__chevron">${icons.chevronDown}</span>
|
||||
</summary>
|
||||
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
|
||||
<div class="cfg-object__content">
|
||||
${sorted.map(([propKey, node]) =>
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: obj[propKey],
|
||||
path: [...path, propKey],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
searchQuery,
|
||||
})
|
||||
)}
|
||||
${allowExtra ? renderMapField({
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderArray(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchQuery?: string;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
|
||||
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
if (!itemsSchema) {
|
||||
return html`
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<div class="array">
|
||||
${arr.map((item, idx) => {
|
||||
const itemPath = [...path, idx];
|
||||
return html`<div class="array-item">
|
||||
<div style="flex: 1;">
|
||||
<div class="cfg-field cfg-field--error">
|
||||
<div class="cfg-field__label">${label}</div>
|
||||
<div class="cfg-field__error">Unsupported array schema. Use Raw mode.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
|
||||
|
||||
return html`
|
||||
<div class="cfg-array">
|
||||
<div class="cfg-array__header">
|
||||
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
|
||||
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__add"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr, defaultValue(itemsSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
<span class="cfg-array__add-icon">${icons.plus}</span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
|
||||
|
||||
${arr.length === 0 ? html`
|
||||
<div class="cfg-array__empty">
|
||||
No items yet. Click "Add" to create one.
|
||||
</div>
|
||||
` : html`
|
||||
<div class="cfg-array__items">
|
||||
${arr.map((item, idx) => html`
|
||||
<div class="cfg-array__item">
|
||||
<div class="cfg-array__item-header">
|
||||
<span class="cfg-array__item-index">#${idx + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__item-remove"
|
||||
title="Remove item"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr];
|
||||
next.splice(idx, 1);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cfg-array__item-content">
|
||||
${renderNode({
|
||||
schema: itemsSchema,
|
||||
value: item,
|
||||
path: itemPath,
|
||||
path: [...path, idx],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
searchQuery,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr];
|
||||
next.splice(idx, 1);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>`;
|
||||
})}
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr];
|
||||
next.push(defaultValue(itemsSchema));
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "boolean") {
|
||||
const displayValue =
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
: typeof schema.default === "boolean"
|
||||
? schema.default
|
||||
: false;
|
||||
return html`
|
||||
<div class="field-row">
|
||||
<div class="field-row__info">
|
||||
${showLabel ? html`<span class="field-row__label">${label}</span>` : nothing}
|
||||
${help ? html`<span class="field-row__help">${help}</span>` : nothing}
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${displayValue}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="toggle-switch__track">
|
||||
<span class="toggle-switch__thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
const displayValue = value ?? schema.default;
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="number"
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
onPatch(path, parsed);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "string") {
|
||||
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
|
||||
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
|
||||
const displayValue = value ?? schema.default ?? "";
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type=${isSensitive ? "password" : "text"}
|
||||
placeholder=${placeholder}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<div class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
<div class="muted">Unsupported type. Use Raw.</div>
|
||||
</div>`;
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMapField(params: {
|
||||
@@ -381,103 +558,115 @@ function renderMapField(params: {
|
||||
reservedKeys: Set<string>;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
|
||||
params;
|
||||
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params;
|
||||
const anySchema = isAnySchema(schema);
|
||||
const entries = Object.entries(value ?? {}).filter(
|
||||
([key]) => !reservedKeys.has(key),
|
||||
);
|
||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||
|
||||
return html`
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span class="muted">Extra entries</span>
|
||||
<div class="cfg-map">
|
||||
<div class="cfg-map__header">
|
||||
<span class="cfg-map__label">Custom entries</span>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
class="cfg-map__add"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
let index = 1;
|
||||
let key = `new-${index}`;
|
||||
let key = `custom-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `new-${index}`;
|
||||
key = `custom-${index}`;
|
||||
}
|
||||
next[key] = anySchema ? {} : defaultValue(schema);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
<span class="cfg-map__add-icon">${icons.plus}</span>
|
||||
Add Entry
|
||||
</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`<div class="muted">No entries yet.</div>`
|
||||
: entries.map(([key, entryValue]) => {
|
||||
|
||||
${entries.length === 0 ? html`
|
||||
<div class="cfg-map__empty">No custom entries.</div>
|
||||
` : html`
|
||||
<div class="cfg-map__items">
|
||||
${entries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
return html`<div class="array-item" style="gap: 8px;">
|
||||
<input
|
||||
class="mono"
|
||||
style="min-width: 140px;"
|
||||
?disabled=${disabled}
|
||||
.value=${key}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) return;
|
||||
const next = { ...(value ?? {}) };
|
||||
if (nextKey in next) return;
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
${anySchema
|
||||
? html`<label class="field" style="margin: 0;">
|
||||
<div class="muted">JSON value</div>
|
||||
<textarea
|
||||
class="mono"
|
||||
rows="5"
|
||||
.value=${fallback}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(valuePath, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(valuePath, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</label>`
|
||||
: renderNode({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: valuePath,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
placeholder="Key"
|
||||
.value=${key}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) return;
|
||||
const next = { ...(value ?? {}) };
|
||||
if (nextKey in next) return;
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-map__item-value">
|
||||
${anySchema
|
||||
? html`
|
||||
<textarea
|
||||
class="cfg-textarea cfg-textarea--sm"
|
||||
placeholder="JSON value"
|
||||
rows="2"
|
||||
.value=${fallback}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(valuePath, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(valuePath, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
`
|
||||
: renderNode({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: valuePath,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Remove entry"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>`;
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,70 +14,79 @@ export type ConfigFormProps = {
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
// SVG Icons for section cards
|
||||
const sectionIcons = {
|
||||
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
|
||||
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
|
||||
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
|
||||
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
|
||||
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
|
||||
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
|
||||
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
|
||||
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
|
||||
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
|
||||
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
|
||||
};
|
||||
|
||||
// Section metadata
|
||||
const SECTION_META: Record<string, { label: string; icon: string; description: string }> = {
|
||||
const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||
env: {
|
||||
label: "Environment Variables",
|
||||
icon: "🔧",
|
||||
description: "Environment variables passed to the gateway process"
|
||||
},
|
||||
update: {
|
||||
label: "Updates",
|
||||
icon: "📦",
|
||||
description: "Auto-update settings and release channel"
|
||||
},
|
||||
agents: {
|
||||
label: "Agents",
|
||||
icon: "🤖",
|
||||
description: "Agent configurations, models, and identities"
|
||||
},
|
||||
auth: {
|
||||
label: "Authentication",
|
||||
icon: "🔐",
|
||||
description: "API keys and authentication profiles"
|
||||
},
|
||||
channels: {
|
||||
label: "Channels",
|
||||
icon: "💬",
|
||||
description: "Messaging channels (Telegram, Discord, Slack, etc.)"
|
||||
},
|
||||
messages: {
|
||||
label: "Messages",
|
||||
icon: "📨",
|
||||
description: "Message handling and routing settings"
|
||||
},
|
||||
commands: {
|
||||
label: "Commands",
|
||||
icon: "⌨️",
|
||||
description: "Custom slash commands"
|
||||
},
|
||||
hooks: {
|
||||
label: "Hooks",
|
||||
icon: "🪝",
|
||||
description: "Webhooks and event hooks"
|
||||
},
|
||||
skills: {
|
||||
label: "Skills",
|
||||
icon: "✨",
|
||||
description: "Skill packs and capabilities"
|
||||
},
|
||||
tools: {
|
||||
label: "Tools",
|
||||
icon: "🛠️",
|
||||
description: "Tool configurations (browser, search, etc.)"
|
||||
},
|
||||
gateway: {
|
||||
label: "Gateway",
|
||||
icon: "🌐",
|
||||
description: "Gateway server settings (port, auth, binding)"
|
||||
},
|
||||
wizard: {
|
||||
label: "Setup Wizard",
|
||||
icon: "🧙",
|
||||
description: "Setup wizard state and history"
|
||||
},
|
||||
};
|
||||
|
||||
function getSectionIcon(key: string) {
|
||||
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
|
||||
}
|
||||
|
||||
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -161,14 +170,13 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
${entries.map(([key, node]) => {
|
||||
const meta = SECTION_META[key] ?? {
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
icon: "📄",
|
||||
description: node.description ?? ""
|
||||
};
|
||||
|
||||
return html`
|
||||
<section class="config-section-card" id="config-section-${key}">
|
||||
<div class="config-section-card__header">
|
||||
<span class="config-section-card__icon">${meta.icon}</span>
|
||||
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
|
||||
<div class="config-section-card__titles">
|
||||
<h3 class="config-section-card__title">${meta.label}</h3>
|
||||
${meta.description ? html`
|
||||
|
||||
@@ -30,22 +30,44 @@ export type ConfigProps = {
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
// Section definitions with icons
|
||||
const SECTIONS: Array<{ key: string; label: string; icon: string }> = [
|
||||
{ key: "env", label: "Environment", icon: "🔧" },
|
||||
{ key: "update", label: "Updates", icon: "📦" },
|
||||
{ key: "agents", label: "Agents", icon: "🤖" },
|
||||
{ key: "auth", label: "Authentication", icon: "🔐" },
|
||||
{ key: "channels", label: "Channels", icon: "💬" },
|
||||
{ key: "messages", label: "Messages", icon: "📨" },
|
||||
{ key: "commands", label: "Commands", icon: "⌨️" },
|
||||
{ key: "hooks", label: "Hooks", icon: "🪝" },
|
||||
{ key: "skills", label: "Skills", icon: "✨" },
|
||||
{ key: "tools", label: "Tools", icon: "🛠️" },
|
||||
{ key: "gateway", label: "Gateway", icon: "🌐" },
|
||||
{ key: "wizard", label: "Setup Wizard", icon: "🧙" },
|
||||
// SVG Icons for sidebar
|
||||
const sidebarIcons = {
|
||||
all: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`,
|
||||
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
|
||||
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
|
||||
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
|
||||
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
|
||||
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
|
||||
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
|
||||
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
|
||||
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
|
||||
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
|
||||
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
|
||||
};
|
||||
|
||||
// Section definitions
|
||||
const SECTIONS: Array<{ key: string; label: string }> = [
|
||||
{ key: "env", label: "Environment" },
|
||||
{ key: "update", label: "Updates" },
|
||||
{ key: "agents", label: "Agents" },
|
||||
{ key: "auth", label: "Authentication" },
|
||||
{ key: "channels", label: "Channels" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "commands", label: "Commands" },
|
||||
{ key: "hooks", label: "Hooks" },
|
||||
{ key: "skills", label: "Skills" },
|
||||
{ key: "tools", label: "Tools" },
|
||||
{ key: "gateway", label: "Gateway" },
|
||||
{ key: "wizard", label: "Setup Wizard" },
|
||||
];
|
||||
|
||||
function getSectionIcon(key: string) {
|
||||
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
original: Record<string, unknown> | null,
|
||||
current: Record<string, unknown> | null
|
||||
@@ -117,7 +139,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
const knownKeys = new Set(SECTIONS.map(s => s.key));
|
||||
const extraSections = Object.keys(schemaProps)
|
||||
.filter(k => !knownKeys.has(k))
|
||||
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1), icon: "📄" }));
|
||||
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
|
||||
|
||||
const allSections = [...availableSections, ...extraSections];
|
||||
|
||||
@@ -163,7 +185,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
|
||||
@click=${() => props.onSectionChange(null)}
|
||||
>
|
||||
<span class="config-nav__icon">📋</span>
|
||||
<span class="config-nav__icon">${sidebarIcons.all}</span>
|
||||
<span class="config-nav__label">All Settings</span>
|
||||
</button>
|
||||
${allSections.map(section => html`
|
||||
@@ -171,7 +193,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
|
||||
@click=${() => props.onSectionChange(section.key)}
|
||||
>
|
||||
<span class="config-nav__icon">${section.icon}</span>
|
||||
<span class="config-nav__icon">${getSectionIcon(section.key)}</span>
|
||||
<span class="config-nav__label">${section.label}</span>
|
||||
</button>
|
||||
`)}
|
||||
|
||||
Reference in New Issue
Block a user