feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view
Major redesign of the config page: Layout: - Sidebar navigation with section list - Search input to filter settings - Section cards with icons and descriptions - Responsive design for mobile (stacked layout) Fields: - New toggle switches for booleans (replaces checkboxes) - Improved field-row layout with label, help text, and control - Better fieldset and array styling Features: - Diff view showing pending changes before save - Original value tracking for comparison - Section filtering via sidebar nav - Search across setting names, descriptions, and nested properties Styling: - Dedicated config.css with all new styles - Dark and light theme support - Smooth animations and transitions - Mobile-first responsive breakpoints
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/config.css";
|
||||
|
||||
708
ui/src/styles/config.css
Normal file
708
ui/src/styles/config.css
Normal file
@@ -0,0 +1,708 @@
|
||||
/* ===========================================
|
||||
Config Page - Modern Layout
|
||||
=========================================== */
|
||||
|
||||
/* Layout Container */
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: calc(100vh - 140px);
|
||||
margin: -16px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.config-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-sidebar {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.config-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-sidebar__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.config-sidebar__footer {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.config-search {
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.config-search__icon {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.config-search__input {
|
||||
width: 100%;
|
||||
padding: 10px 12px 10px 38px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.config-search__input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-search__input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.config-search__clear {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.config-search__clear:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.config-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.config-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.config-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-nav__item:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.config-nav__item.active {
|
||||
background: rgba(245, 159, 74, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-nav__item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.config-nav__icon {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-nav__label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Mode Toggle */
|
||||
.config-mode-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-mode-toggle {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.config-mode-toggle__btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.config-mode-toggle__btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-mode-toggle__btn.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-mode-toggle__btn.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.config-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* Actions Bar */
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-actions {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.config-actions__left,
|
||||
.config-actions__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-changes-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(245, 159, 74, 0.2);
|
||||
border: 1px solid rgba(245, 159, 74, 0.4);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Diff Panel */
|
||||
.config-diff {
|
||||
margin: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-diff {
|
||||
background: rgba(245, 159, 74, 0.05);
|
||||
}
|
||||
|
||||
.config-diff__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.config-diff__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-diff__chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.config-diff[open] .config-diff__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.config-diff__content {
|
||||
padding: 0 14px 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-diff__item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-diff__item {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.config-diff__path {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-diff__values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-diff__from {
|
||||
color: var(--danger);
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.config-diff__arrow {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-diff__to {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.config-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-raw-field textarea {
|
||||
min-height: 400px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.config-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-loading__spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.config-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-empty__icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.config-empty__text {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Section Cards */
|
||||
.config-form--modern {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-section-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%),
|
||||
rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-card {
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7) 70%);
|
||||
}
|
||||
|
||||
.config-section-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-card__header {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.config-section-card__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.config-section-card__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-section-card__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.config-section-card__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-section-card__content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch__track {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
transition: background 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .toggle-switch__track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toggle-switch__thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__track {
|
||||
background: rgba(43, 217, 127, 0.3);
|
||||
border-color: rgba(43, 217, 127, 0.5);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__track .toggle-switch__thumb {
|
||||
transform: translateX(20px);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.toggle-switch input:focus + .toggle-switch__track {
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
.toggle-switch input:disabled + .toggle-switch__track {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Field Row (for toggles) */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .field-row {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.field-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-row__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field-row__help {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Pills */
|
||||
.pill--sm {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
border-color: rgba(43, 217, 127, 0.5);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.pill--danger {
|
||||
border-color: rgba(255, 92, 92, 0.5);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Improved Fieldset */
|
||||
.config-form--modern .fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-top: 12px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-form--modern .fieldset {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.config-form--modern .fieldset > .legend {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.config-form--modern .field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-form--modern .field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-form--modern .field span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-form--modern .field input,
|
||||
.config-form--modern .field select,
|
||||
.config-form--modern .field textarea {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Array improvements */
|
||||
.config-form--modern .array {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-form--modern .array-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-form--modern .array-item {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.config-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.config-nav__item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.config-nav__icon {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.config-sidebar__footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-actions__left {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-actions__right {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-diff__values {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-form--modern .array-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.config-nav__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-nav__item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.config-section-card__header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.config-section-card__content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -472,9 +472,14 @@ export function renderApp(state: AppViewState) {
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
onRawChange: (next) => (state.configRaw = next),
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => (state.configActiveSection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
|
||||
@@ -139,8 +139,11 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() configSchemaLoading = false;
|
||||
@state() configUiHints: ConfigUiHints = {};
|
||||
@state() configForm: Record<string, unknown> | null = null;
|
||||
@state() configFormOriginal: Record<string, unknown> | null = null;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: string | null = null;
|
||||
|
||||
@state() channelsLoading = false;
|
||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
|
||||
@@ -28,8 +28,11 @@ export type ConfigState = {
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormDirty: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
@@ -93,6 +96,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
|
||||
if (!state.configFormDirty) {
|
||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,10 @@ export function renderNode(params: {
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchQuery?: string;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult | typeof nothing {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const type = schemaType(schema);
|
||||
const hint = hintForPath(path, hints);
|
||||
@@ -303,21 +304,24 @@ export function renderNode(params: {
|
||||
? schema.default
|
||||
: false;
|
||||
return html`
|
||||
<label class="field checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${displayValue}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help
|
||||
? html`<div class="muted" style="grid-column: 1 / -1;">
|
||||
${help}
|
||||
</div>`
|
||||
: nothing}
|
||||
</label>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,45 +9,104 @@ export type ConfigFormProps = {
|
||||
value: Record<string, unknown> | null;
|
||||
disabled?: boolean;
|
||||
unsupportedPaths?: string[];
|
||||
searchQuery?: string;
|
||||
activeSection?: string | null;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
// Define logical section groupings
|
||||
const SECTION_CONFIG: Record<string, { label: string; icon: string; order: number }> = {
|
||||
// Core
|
||||
env: { label: "Environment", icon: "🔧", order: 0 },
|
||||
update: { label: "Updates", icon: "📦", order: 1 },
|
||||
|
||||
// Identity & Agents
|
||||
agents: { label: "Agents", icon: "🤖", order: 10 },
|
||||
auth: { label: "Authentication", icon: "🔐", order: 11 },
|
||||
|
||||
// Communication
|
||||
channels: { label: "Channels", icon: "💬", order: 20 },
|
||||
messages: { label: "Messages", icon: "📨", order: 21 },
|
||||
|
||||
// Automation
|
||||
commands: { label: "Commands", icon: "⌨️", order: 30 },
|
||||
hooks: { label: "Hooks", icon: "🪝", order: 31 },
|
||||
skills: { label: "Skills", icon: "✨", order: 32 },
|
||||
|
||||
// Tools & Gateway
|
||||
tools: { label: "Tools", icon: "🛠️", order: 40 },
|
||||
gateway: { label: "Gateway", icon: "🌐", order: 41 },
|
||||
|
||||
// System
|
||||
wizard: { label: "Setup Wizard", icon: "🧙", order: 50 },
|
||||
// Section metadata
|
||||
const SECTION_META: Record<string, { label: string; icon: 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"
|
||||
},
|
||||
};
|
||||
|
||||
// Logical groupings for the accordion layout
|
||||
const SECTION_GROUPS: Array<{ title: string; keys: string[] }> = [
|
||||
{ title: "Core Settings", keys: ["env", "update"] },
|
||||
{ title: "Identity & Agents", keys: ["agents", "auth"] },
|
||||
{ title: "Communication", keys: ["channels", "messages"] },
|
||||
{ title: "Automation", keys: ["commands", "hooks", "skills"] },
|
||||
{ title: "Tools & Gateway", keys: ["tools", "gateway"] },
|
||||
{ title: "System", keys: ["wizard"] },
|
||||
];
|
||||
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
const meta = SECTION_META[key];
|
||||
|
||||
// Check key name
|
||||
if (key.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Check label and description
|
||||
if (meta) {
|
||||
if (meta.label.toLowerCase().includes(q)) return true;
|
||||
if (meta.description.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
|
||||
// Check schema title/description
|
||||
if (schema.title?.toLowerCase().includes(q)) return true;
|
||||
if (schema.description?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Deep search in properties
|
||||
if (schema.properties) {
|
||||
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
|
||||
if (propKey.toLowerCase().includes(q)) return true;
|
||||
if (propSchema.title?.toLowerCase().includes(q)) return true;
|
||||
if (propSchema.description?.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function renderConfigForm(props: ConfigFormProps) {
|
||||
if (!props.schema) {
|
||||
@@ -60,76 +119,79 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
}
|
||||
const unsupported = new Set(props.unsupportedPaths ?? []);
|
||||
const properties = schema.properties;
|
||||
const allKeys = new Set(Object.keys(properties));
|
||||
const searchQuery = props.searchQuery ?? "";
|
||||
const activeSection = props.activeSection;
|
||||
|
||||
// Collect any keys not in our defined groups
|
||||
const groupedKeys = new Set(SECTION_GROUPS.flatMap(g => g.keys));
|
||||
const ungroupedKeys = [...allKeys].filter(k => !groupedKeys.has(k));
|
||||
// Filter and sort entries
|
||||
let entries = Object.entries(properties);
|
||||
|
||||
// Filter by active section
|
||||
if (activeSection) {
|
||||
entries = entries.filter(([key]) => key === activeSection);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
entries = entries.filter(([key, node]) => matchesSearch(key, node, searchQuery));
|
||||
}
|
||||
|
||||
// Sort by hint order, then alphabetically
|
||||
entries.sort((a, b) => {
|
||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
|
||||
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
// Build the groups with their entries
|
||||
const groups = SECTION_GROUPS.map(group => {
|
||||
const entries = group.keys
|
||||
.filter(key => allKeys.has(key))
|
||||
.map(key => ({ key, node: properties[key] }));
|
||||
return { ...group, entries };
|
||||
}).filter(group => group.entries.length > 0);
|
||||
|
||||
// Add ungrouped keys as "Other" if any exist
|
||||
if (ungroupedKeys.length > 0) {
|
||||
const sortedUngrouped = ungroupedKeys.sort((a, b) => {
|
||||
const orderA = hintForPath([a], props.uiHints)?.order ?? 100;
|
||||
const orderB = hintForPath([b], props.uiHints)?.order ?? 100;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
groups.push({
|
||||
title: "Other",
|
||||
keys: sortedUngrouped,
|
||||
entries: sortedUngrouped.map(key => ({ key, node: properties[key] })),
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
return html`
|
||||
<div class="config-empty">
|
||||
<div class="config-empty__icon">🔍</div>
|
||||
<div class="config-empty__text">
|
||||
${searchQuery
|
||||
? `No settings match "${searchQuery}"`
|
||||
: "No settings in this section"}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="config-form config-form--sectioned">
|
||||
${groups.map((group, groupIndex) => html`
|
||||
<details class="config-section" ?open=${groupIndex === 0}>
|
||||
<summary class="config-section__header">
|
||||
<span class="config-section__title">${group.title}</span>
|
||||
<span class="config-section__count">${group.entries.length} ${group.entries.length === 1 ? 'setting' : 'settings'}</span>
|
||||
<svg class="config-section__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="config-section__content">
|
||||
${group.entries.map(({ key, node }) => {
|
||||
const sectionInfo = SECTION_CONFIG[key];
|
||||
const icon = sectionInfo?.icon ?? "📄";
|
||||
const label = sectionInfo?.label ?? key;
|
||||
|
||||
return html`
|
||||
<div class="config-field-group">
|
||||
<div class="config-field-group__header">
|
||||
<span class="config-field-group__icon">${icon}</span>
|
||||
<span class="config-field-group__label">${label}</span>
|
||||
</div>
|
||||
<div class="config-field-group__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
`)}
|
||||
<div class="config-form config-form--modern">
|
||||
${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>
|
||||
<div class="config-section-card__titles">
|
||||
<h3 class="config-section-card__title">${meta.label}</h3>
|
||||
${meta.description ? html`
|
||||
<p class="config-section-card__desc">${meta.description}</p>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-section-card__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
searchQuery,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -16,15 +16,79 @@ export type ConfigProps = {
|
||||
uiHints: ConfigUiHints;
|
||||
formMode: "form" | "raw";
|
||||
formValue: Record<string, unknown> | null;
|
||||
originalValue: Record<string, unknown> | null;
|
||||
searchQuery: string;
|
||||
activeSection: string | null;
|
||||
onRawChange: (next: string) => void;
|
||||
onFormModeChange: (mode: "form" | "raw") => void;
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSectionChange: (section: string | null) => void;
|
||||
onReload: () => void;
|
||||
onSave: () => void;
|
||||
onApply: () => void;
|
||||
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: "🧙" },
|
||||
];
|
||||
|
||||
function computeDiff(
|
||||
original: Record<string, unknown> | null,
|
||||
current: Record<string, unknown> | null
|
||||
): Array<{ path: string; from: unknown; to: unknown }> {
|
||||
if (!original || !current) return [];
|
||||
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
|
||||
|
||||
function compare(orig: unknown, curr: unknown, path: string) {
|
||||
if (orig === curr) return;
|
||||
if (typeof orig !== typeof curr) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
return;
|
||||
}
|
||||
if (typeof orig !== "object" || orig === null || curr === null) {
|
||||
if (orig !== curr) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(orig) && Array.isArray(curr)) {
|
||||
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const origObj = orig as Record<string, unknown>;
|
||||
const currObj = curr as Record<string, unknown>;
|
||||
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
|
||||
for (const key of allKeys) {
|
||||
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
|
||||
}
|
||||
}
|
||||
|
||||
compare(original, current, "");
|
||||
return changes;
|
||||
}
|
||||
|
||||
function truncateValue(value: unknown, maxLen = 40): string {
|
||||
const str = JSON.stringify(value);
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const validity =
|
||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||
@@ -45,97 +109,201 @@ export function renderConfig(props: ConfigProps) {
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
const canUpdate = props.connected && !props.applying && !props.updating;
|
||||
|
||||
// Get available sections from schema
|
||||
const schemaProps = analysis.schema?.properties ?? {};
|
||||
const availableSections = SECTIONS.filter(s => s.key in schemaProps);
|
||||
|
||||
// Add any sections in schema but not in our list
|
||||
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: "📄" }));
|
||||
|
||||
const allSections = [...availableSections, ...extraSections];
|
||||
|
||||
// Compute diff for showing changes
|
||||
const diff = props.formMode === "form"
|
||||
? computeDiff(props.originalValue, props.formValue)
|
||||
: [];
|
||||
const hasChanges = diff.length > 0;
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="config-header">
|
||||
<div class="config-header__top">
|
||||
<div class="row">
|
||||
<div class="card-title">Config</div>
|
||||
<span class="pill">${validity}</span>
|
||||
</div>
|
||||
<div class="toggle-group">
|
||||
<div class="config-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="config-sidebar">
|
||||
<div class="config-sidebar__header">
|
||||
<div class="config-sidebar__title">Settings</div>
|
||||
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="config-search">
|
||||
<svg class="config-search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="config-search__input"
|
||||
placeholder="Search settings..."
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${props.searchQuery ? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
@click=${() => props.onSearchChange("")}
|
||||
>×</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<!-- Section nav -->
|
||||
<nav class="config-nav">
|
||||
<button
|
||||
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
|
||||
@click=${() => props.onSectionChange(null)}
|
||||
>
|
||||
<span class="config-nav__icon">📋</span>
|
||||
<span class="config-nav__label">All Settings</span>
|
||||
</button>
|
||||
${allSections.map(section => html`
|
||||
<button
|
||||
class="btn btn--sm ${props.formMode === "form" ? "primary" : ""}"
|
||||
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__label">${section.label}</span>
|
||||
</button>
|
||||
`)}
|
||||
</nav>
|
||||
|
||||
<!-- Mode toggle at bottom -->
|
||||
<div class="config-sidebar__footer">
|
||||
<div class="config-mode-toggle">
|
||||
<button
|
||||
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
||||
?disabled=${props.schemaLoading || !props.schema}
|
||||
@click=${() => props.onFormModeChange("form")}
|
||||
>
|
||||
Form
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm ${props.formMode === "raw" ? "primary" : ""}"
|
||||
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
|
||||
@click=${() => props.onFormModeChange("raw")}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-header__actions">
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${!canSave}
|
||||
@click=${props.onSave}
|
||||
>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!canApply}
|
||||
@click=${props.onApply}
|
||||
>
|
||||
${props.applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!canUpdate}
|
||||
@click=${props.onUpdate}
|
||||
>
|
||||
${props.updating ? "Updating…" : "Update"}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="config-main">
|
||||
<!-- Action bar -->
|
||||
<div class="config-actions">
|
||||
<div class="config-actions__left">
|
||||
${hasChanges ? html`
|
||||
<span class="config-changes-badge">${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}</span>
|
||||
` : html`
|
||||
<span class="config-status muted">No changes</span>
|
||||
`}
|
||||
</div>
|
||||
<div class="config-actions__right">
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${!canSave}
|
||||
@click=${props.onSave}
|
||||
>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!canApply}
|
||||
@click=${props.onApply}
|
||||
>
|
||||
${props.applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!canUpdate}
|
||||
@click=${props.onUpdate}
|
||||
>
|
||||
${props.updating ? "Updating…" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff panel -->
|
||||
${hasChanges ? html`
|
||||
<details class="config-diff">
|
||||
<summary class="config-diff__summary">
|
||||
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
|
||||
<svg class="config-diff__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="config-diff__content">
|
||||
${diff.map(change => html`
|
||||
<div class="config-diff__item">
|
||||
<div class="config-diff__path">${change.path}</div>
|
||||
<div class="config-diff__values">
|
||||
<span class="config-diff__from">${truncateValue(change.from)}</span>
|
||||
<span class="config-diff__arrow">→</span>
|
||||
<span class="config-diff__to">${truncateValue(change.to)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</details>
|
||||
` : nothing}
|
||||
|
||||
<div class="muted" style="margin-top: 10px; font-size: 12px;">
|
||||
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Apply &
|
||||
Update restart the gateway.
|
||||
</div>
|
||||
<!-- Form content -->
|
||||
<div class="config-content">
|
||||
${props.formMode === "form"
|
||||
? html`
|
||||
${props.schemaLoading
|
||||
? html`<div class="config-loading">
|
||||
<div class="config-loading__spinner"></div>
|
||||
<span>Loading schema…</span>
|
||||
</div>`
|
||||
: renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
disabled: props.loading || !props.formValue,
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
onPatch: props.onFormPatch,
|
||||
searchQuery: props.searchQuery,
|
||||
activeSection: props.activeSection,
|
||||
})}
|
||||
${formUnsafe
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
Form view can't safely edit some fields.
|
||||
Use Raw to avoid losing config entries.
|
||||
</div>`
|
||||
: nothing}
|
||||
`
|
||||
: html`
|
||||
<label class="field config-raw-field">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
`}
|
||||
</div>
|
||||
|
||||
|
||||
${props.formMode === "form"
|
||||
? html`<div style="margin-top: 12px;">
|
||||
${props.schemaLoading
|
||||
? html`<div class="muted">Loading schema…</div>`
|
||||
: renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
disabled: props.loading || !props.formValue,
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
onPatch: props.onFormPatch,
|
||||
})}
|
||||
${formUnsafe
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
Form view can’t safely edit some fields.
|
||||
Use Raw to avoid losing config entries.
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: html`<label class="field" style="margin-top: 12px;">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>`}
|
||||
|
||||
${props.issues.length > 0
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
|
||||
</div>`
|
||||
: nothing}
|
||||
</section>
|
||||
${props.issues.length > 0
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
|
||||
</div>`
|
||||
: nothing}
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user