Merge e132cc08d6 into a7534dc223
This commit is contained in:
commit
2a2ecd5646
6
.gitignore
vendored
6
.gitignore
vendored
@ -71,3 +71,9 @@ USER.md
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# AI Powers (external project resources)
|
||||
.ai-powers/
|
||||
|
||||
# UI Redesign workspace
|
||||
.redesign/
|
||||
|
||||
@ -5,7 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Moltbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#1A1816" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#FAFAF9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<!-- Poppins Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<moltbot-app></moltbot-app>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/color-themes.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
|
||||
@ -1,192 +1,350 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
/**
|
||||
* Clawdbot UI - Base Styles
|
||||
* Design System: Tangerine Theme
|
||||
* Font: Poppins
|
||||
* Modes: Light & Dark
|
||||
* Target: WCAG AAA compliance
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Background - Warmer dark with depth */
|
||||
--bg: #12141a;
|
||||
--bg-accent: #14161d;
|
||||
--bg-elevated: #1a1d25;
|
||||
--bg-hover: #262a35;
|
||||
--bg-muted: #262a35;
|
||||
|
||||
/* Card / Surface - More contrast between levels */
|
||||
--card: #181b22;
|
||||
:root,
|
||||
:root[data-color-theme="tangerine"] {
|
||||
/* ============================================
|
||||
COLOR SYSTEM - DARK MODE (Default)
|
||||
============================================ */
|
||||
|
||||
/* Background Colors - Muted Dark Theme */
|
||||
--color-bg-primary: #1A1816;
|
||||
--color-bg-secondary: #242220;
|
||||
--color-bg-tertiary: #2E2C28;
|
||||
--color-bg-elevated: #3A3834;
|
||||
|
||||
/* Legacy aliases for compatibility */
|
||||
--bg: var(--color-bg-primary);
|
||||
--bg-accent: var(--color-bg-secondary);
|
||||
--bg-elevated: var(--color-bg-elevated);
|
||||
--bg-hover: var(--color-bg-tertiary);
|
||||
--bg-muted: var(--color-bg-tertiary);
|
||||
--bg-content: var(--color-bg-secondary);
|
||||
|
||||
/* Card / Surface */
|
||||
--card: var(--color-bg-secondary);
|
||||
--card-foreground: #f4f4f5;
|
||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||
--popover: #181b22;
|
||||
--card-highlight: rgba(255, 159, 64, 0.03);
|
||||
--popover: var(--color-bg-secondary);
|
||||
--popover-foreground: #f4f4f5;
|
||||
|
||||
|
||||
/* Panel */
|
||||
--panel: #12141a;
|
||||
--panel-strong: #1a1d25;
|
||||
--panel-hover: #262a35;
|
||||
--chrome: rgba(18, 20, 26, 0.95);
|
||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||
|
||||
/* Text - Slightly warmer */
|
||||
--text: #e4e4e7;
|
||||
--text-strong: #fafafa;
|
||||
--chat-text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
/* Border - Subtle but defined */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
--ring: #ff5c5c;
|
||||
|
||||
/* Accent - Punchy signature red */
|
||||
--accent: #ff5c5c;
|
||||
--accent-hover: #ff7070;
|
||||
--accent-muted: #ff5c5c;
|
||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||
--accent-foreground: #fafafa;
|
||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||
--primary: #ff5c5c;
|
||||
--panel: var(--color-bg-primary);
|
||||
--panel-strong: var(--color-bg-elevated);
|
||||
--panel-hover: var(--color-bg-tertiary);
|
||||
--chrome: rgba(26, 24, 22, 0.95);
|
||||
--chrome-strong: rgba(26, 24, 22, 0.98);
|
||||
|
||||
/* Text Colors - High Contrast */
|
||||
--color-text-primary: #FFFFFF;
|
||||
--color-text-secondary: #E0E0E0;
|
||||
--color-text-tertiary: #B0B0B0;
|
||||
--color-text-disabled: #666666;
|
||||
--color-text-inverse: #1A1A1A;
|
||||
|
||||
/* Legacy aliases */
|
||||
--text: var(--color-text-secondary);
|
||||
--text-strong: var(--color-text-primary);
|
||||
--chat-text: var(--color-text-secondary);
|
||||
--muted: var(--color-text-tertiary);
|
||||
--muted-strong: #71717a;
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
|
||||
/* Accent Colors - Tangerine Theme */
|
||||
--color-accent-primary: #FF9F40;
|
||||
--color-accent-primary-hover: #FF8C2E;
|
||||
--color-accent-primary-active: #FF7A1C;
|
||||
--color-accent-secondary: #FFB366;
|
||||
|
||||
/* Legacy aliases */
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-muted: var(--color-accent-primary);
|
||||
--accent-subtle: rgba(255, 159, 64, 0.15);
|
||||
--accent-foreground: var(--color-text-inverse);
|
||||
--accent-glow: rgba(255, 159, 64, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary - Teal accent for variety */
|
||||
--secondary: #1e2028;
|
||||
|
||||
/* Secondary Accent - Teal */
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: #f4f4f5;
|
||||
--accent-2: #14b8a6;
|
||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
||||
|
||||
/* Semantic - More saturated */
|
||||
--ok: #22c55e;
|
||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
||||
--destructive: #ef4444;
|
||||
|
||||
/* Semantic Colors - Dark Mode */
|
||||
--color-accent-success: #4CAF50;
|
||||
--color-accent-success-hover: #45A049;
|
||||
--color-accent-error: #FF5252;
|
||||
--color-accent-error-hover: #FF3838;
|
||||
--color-accent-warning: #FFB300;
|
||||
--color-accent-warning-hover: #FFA000;
|
||||
--color-accent-info: #29B6F6;
|
||||
--color-accent-info-hover: #039BE5;
|
||||
|
||||
/* Legacy aliases */
|
||||
--ok: var(--color-accent-success);
|
||||
--ok-muted: rgba(76, 175, 80, 0.75);
|
||||
--ok-subtle: rgba(76, 175, 80, 0.12);
|
||||
--destructive: var(--color-accent-error);
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #f59e0b;
|
||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
||||
--danger: #ef4444;
|
||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Focus - With glow */
|
||||
--focus: rgba(255, 92, 92, 0.25);
|
||||
--warn: var(--color-accent-warning);
|
||||
--warn-muted: rgba(255, 179, 0, 0.75);
|
||||
--warn-subtle: rgba(255, 179, 0, 0.12);
|
||||
--danger: var(--color-accent-error);
|
||||
--danger-muted: rgba(255, 82, 82, 0.75);
|
||||
--danger-subtle: rgba(255, 82, 82, 0.12);
|
||||
--info: var(--color-accent-info);
|
||||
|
||||
/* Border Colors - Dark Mode */
|
||||
--color-border-subtle: rgba(255, 159, 64, 0.1);
|
||||
--color-border-default: rgba(255, 159, 64, 0.2);
|
||||
--color-border-strong: rgba(255, 159, 64, 0.3);
|
||||
|
||||
/* Legacy aliases */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
--ring: var(--color-accent-primary);
|
||||
|
||||
/* Focus Colors */
|
||||
--color-focus-ring: var(--color-accent-primary);
|
||||
--color-focus-ring-error: var(--color-accent-error);
|
||||
--focus: rgba(255, 159, 64, 0.25);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
||||
|
||||
|
||||
/* Glassmorphism - Dark Mode */
|
||||
--color-glass-bg: rgba(255, 159, 64, 0.05);
|
||||
--color-glass-border: rgba(255, 159, 64, 0.15);
|
||||
--glass-blur: 20px;
|
||||
|
||||
/* Chat Bubbles - Dark Mode */
|
||||
--color-chat-user: var(--color-accent-primary);
|
||||
--color-chat-assistant: var(--color-bg-tertiary);
|
||||
--color-chat-system: var(--color-bg-elevated);
|
||||
|
||||
/* Grid */
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
|
||||
|
||||
/* Theme transition */
|
||||
--theme-switch-x: 50%;
|
||||
--theme-switch-y: 50%;
|
||||
|
||||
/* Typography - Space Grotesk for personality */
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Shadows - Richer with subtle color */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
|
||||
/* Radii - Slightly larger for friendlier feel */
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
|
||||
/* Font Families */
|
||||
--font-family-base: 'Poppins', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-family-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Legacy aliases */
|
||||
--mono: var(--font-family-mono);
|
||||
--font-body: var(--font-family-base);
|
||||
--font-display: var(--font-family-base);
|
||||
|
||||
/* Font Sizes */
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-md: 1.125rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 1.875rem;
|
||||
--font-size-3xl: 2.25rem;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-0: 0;
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
|
||||
/* Touch Targets */
|
||||
--size-touch-min: 44px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--radius: 8px;
|
||||
|
||||
/* Transitions - Snappy but smooth */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
--shadow-focus: 0 0 0 3px var(--color-focus-ring);
|
||||
|
||||
/* Transitions */
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 350ms;
|
||||
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme - Clean with subtle warmth */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #fafafa;
|
||||
--bg-accent: #f5f5f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #f0f0f0;
|
||||
--bg-muted: #f0f0f0;
|
||||
--bg-content: #f5f5f5;
|
||||
/* ============================================
|
||||
LIGHT MODE THEME
|
||||
============================================ */
|
||||
|
||||
--card: #ffffff;
|
||||
:root[data-theme="light"],
|
||||
:root[data-color-theme="tangerine"][data-theme="light"] {
|
||||
/* Background Colors - Light Mode (Muted) */
|
||||
--color-bg-primary: #FAFAF9;
|
||||
--color-bg-secondary: #F5F4F2;
|
||||
--color-bg-tertiary: #EFEEE9;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
|
||||
/* Legacy aliases */
|
||||
--bg: var(--color-bg-primary);
|
||||
--bg-accent: var(--color-bg-secondary);
|
||||
--bg-elevated: var(--color-bg-elevated);
|
||||
--bg-hover: var(--color-bg-tertiary);
|
||||
--bg-muted: var(--color-bg-tertiary);
|
||||
--bg-content: var(--color-bg-secondary);
|
||||
|
||||
--card: var(--color-bg-elevated);
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||
--popover: #ffffff;
|
||||
--card-highlight: rgba(255, 140, 66, 0.03);
|
||||
--popover: var(--color-bg-elevated);
|
||||
--popover-foreground: #18181b;
|
||||
|
||||
--panel: #fafafa;
|
||||
--panel-strong: #f5f5f5;
|
||||
--panel-hover: #ebebeb;
|
||||
--chrome: rgba(250, 250, 250, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||
|
||||
|
||||
--panel: var(--color-bg-primary);
|
||||
--panel-strong: var(--color-bg-secondary);
|
||||
--panel-hover: var(--color-bg-tertiary);
|
||||
--chrome: rgba(250, 250, 249, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 249, 0.98);
|
||||
|
||||
/* Text Colors - Light Mode */
|
||||
--color-text-primary: #1A1410;
|
||||
--color-text-secondary: #4A3F35;
|
||||
--color-text-tertiary: #6A5A4A;
|
||||
--color-text-disabled: #9A8A7A;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
/* Legacy aliases */
|
||||
--text: #3f3f46;
|
||||
--text-strong: #18181b;
|
||||
--text-strong: var(--color-text-primary);
|
||||
--chat-text: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--accent-muted: #dc2626;
|
||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||
|
||||
/* Accent Colors - Light Mode */
|
||||
--color-accent-primary: #FF8C42;
|
||||
--color-accent-primary-hover: #FF7A2E;
|
||||
--color-accent-primary-active: #E5681A;
|
||||
--color-accent-secondary: #FFA366;
|
||||
|
||||
/* Legacy aliases */
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-muted: var(--color-accent-primary);
|
||||
--accent-subtle: rgba(255, 140, 66, 0.12);
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||
--primary: #dc2626;
|
||||
--accent-glow: rgba(255, 140, 66, 0.15);
|
||||
--primary: var(--color-accent-primary);
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #f4f4f5;
|
||||
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: #3f3f46;
|
||||
--accent-2: #0d9488;
|
||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
||||
|
||||
--ok: #16a34a;
|
||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
||||
--destructive: #dc2626;
|
||||
|
||||
/* Semantic Colors - Light Mode */
|
||||
--color-accent-success: #2E7D32;
|
||||
--color-accent-success-hover: #1B5E20;
|
||||
--color-accent-error: #D32F2F;
|
||||
--color-accent-error-hover: #C62828;
|
||||
--color-accent-warning: #F57C00;
|
||||
--color-accent-warning-hover: #E65100;
|
||||
--color-accent-info: #0288D1;
|
||||
--color-accent-info-hover: #01579B;
|
||||
|
||||
/* Legacy aliases */
|
||||
--ok: var(--color-accent-success);
|
||||
--ok-muted: rgba(46, 125, 50, 0.75);
|
||||
--ok-subtle: rgba(46, 125, 50, 0.1);
|
||||
--destructive: var(--color-accent-error);
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #d97706;
|
||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
||||
--danger: #dc2626;
|
||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--info: #2563eb;
|
||||
|
||||
--focus: rgba(220, 38, 38, 0.2);
|
||||
--warn: var(--color-accent-warning);
|
||||
--warn-muted: rgba(245, 124, 0, 0.75);
|
||||
--warn-subtle: rgba(245, 124, 0, 0.1);
|
||||
--danger: var(--color-accent-error);
|
||||
--danger-muted: rgba(211, 47, 47, 0.75);
|
||||
--danger-subtle: rgba(211, 47, 47, 0.1);
|
||||
--info: var(--color-accent-info);
|
||||
|
||||
/* Border Colors - Light Mode */
|
||||
--color-border-subtle: rgba(255, 140, 66, 0.15);
|
||||
--color-border-default: rgba(255, 140, 66, 0.25);
|
||||
--color-border-strong: rgba(255, 140, 66, 0.4);
|
||||
|
||||
/* Legacy aliases */
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
|
||||
/* Focus Colors - Light Mode */
|
||||
--color-focus-ring: var(--color-accent-primary);
|
||||
--color-focus-ring-error: var(--color-accent-error);
|
||||
--focus: rgba(255, 140, 66, 0.2);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
|
||||
/* Glassmorphism - Light Mode */
|
||||
--color-glass-bg: rgba(255, 255, 255, 0.7);
|
||||
--color-glass-border: rgba(255, 140, 66, 0.2);
|
||||
|
||||
/* Chat Bubbles - Light Mode */
|
||||
--color-chat-user: var(--color-accent-primary);
|
||||
--color-chat-assistant: var(--color-bg-secondary);
|
||||
--color-chat-system: var(--color-bg-tertiary);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.05);
|
||||
|
||||
|
||||
/* Light shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -287,7 +445,7 @@ select {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Animations - Polished with spring feel */
|
||||
/* Animations */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -350,14 +508,14 @@ select {
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
|
||||
box-shadow: 0 0 0 rgba(255, 159, 64, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stagger animation delays for grouped elements */
|
||||
/* Stagger animation delays */
|
||||
.stagger-1 { animation-delay: 0ms; }
|
||||
.stagger-2 { animation-delay: 50ms; }
|
||||
.stagger-3 { animation-delay: 100ms; }
|
||||
@ -370,3 +528,26 @@ select {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
134
ui/src/styles/color-themes.css
Normal file
134
ui/src/styles/color-themes.css
Normal file
@ -0,0 +1,134 @@
|
||||
/* ===========================================
|
||||
COLOR THEMES
|
||||
Each theme has dark and light variants
|
||||
=========================================== */
|
||||
|
||||
/* Default: Tangerine (already in base.css) */
|
||||
:root[data-color-theme="tangerine"] {
|
||||
/* Already defined in base.css */
|
||||
}
|
||||
|
||||
/* Ocean Theme - Blue tones */
|
||||
:root[data-color-theme="ocean"] {
|
||||
--color-accent-primary: #0EA5E9;
|
||||
--color-accent-primary-hover: #0284C7;
|
||||
--color-accent-primary-active: #0369A1;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(14, 165, 233, 0.15);
|
||||
--accent-glow: rgba(14, 165, 233, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--ring: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:root[data-color-theme="ocean"][data-theme="light"] {
|
||||
--color-accent-primary: #0284C7;
|
||||
--color-accent-primary-hover: #0369A1;
|
||||
--color-accent-primary-active: #075985;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(2, 132, 199, 0.12);
|
||||
--accent-glow: rgba(2, 132, 199, 0.15);
|
||||
}
|
||||
|
||||
/* Forest Theme - Green tones */
|
||||
:root[data-color-theme="forest"] {
|
||||
--color-accent-primary: #10B981;
|
||||
--color-accent-primary-hover: #059669;
|
||||
--color-accent-primary-active: #047857;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(16, 185, 129, 0.15);
|
||||
--accent-glow: rgba(16, 185, 129, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--ring: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:root[data-color-theme="forest"][data-theme="light"] {
|
||||
--color-accent-primary: #059669;
|
||||
--color-accent-primary-hover: #047857;
|
||||
--color-accent-primary-active: #065F46;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(5, 150, 105, 0.12);
|
||||
--accent-glow: rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
|
||||
/* Sunset Theme - Pink/Rose tones */
|
||||
:root[data-color-theme="sunset"] {
|
||||
--color-accent-primary: #F43F5E;
|
||||
--color-accent-primary-hover: #E11D48;
|
||||
--color-accent-primary-active: #BE123C;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(244, 63, 94, 0.15);
|
||||
--accent-glow: rgba(244, 63, 94, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--ring: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:root[data-color-theme="sunset"][data-theme="light"] {
|
||||
--color-accent-primary: #E11D48;
|
||||
--color-accent-primary-hover: #BE123C;
|
||||
--color-accent-primary-active: #9F1239;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(225, 29, 72, 0.12);
|
||||
--accent-glow: rgba(225, 29, 72, 0.15);
|
||||
}
|
||||
|
||||
/* Lavender Theme - Purple tones */
|
||||
:root[data-color-theme="lavender"] {
|
||||
--color-accent-primary: #A78BFA;
|
||||
--color-accent-primary-hover: #8B5CF6;
|
||||
--color-accent-primary-active: #7C3AED;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(167, 139, 250, 0.15);
|
||||
--accent-glow: rgba(167, 139, 250, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--ring: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:root[data-color-theme="lavender"][data-theme="light"] {
|
||||
--color-accent-primary: #8B5CF6;
|
||||
--color-accent-primary-hover: #7C3AED;
|
||||
--color-accent-primary-active: #6D28D9;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(139, 92, 246, 0.12);
|
||||
--accent-glow: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Slate Theme - Gray/Neutral tones */
|
||||
:root[data-color-theme="slate"] {
|
||||
--color-accent-primary: #64748B;
|
||||
--color-accent-primary-hover: #475569;
|
||||
--color-accent-primary-active: #334155;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(100, 116, 139, 0.15);
|
||||
--accent-glow: rgba(100, 116, 139, 0.25);
|
||||
--primary: var(--color-accent-primary);
|
||||
--ring: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:root[data-color-theme="slate"][data-theme="light"] {
|
||||
--color-accent-primary: #475569;
|
||||
--color-accent-primary-hover: #334155;
|
||||
--color-accent-primary-active: #1E293B;
|
||||
|
||||
--accent: var(--color-accent-primary);
|
||||
--accent-hover: var(--color-accent-primary-hover);
|
||||
--accent-subtle: rgba(71, 85, 105, 0.12);
|
||||
--accent-glow: rgba(71, 85, 105, 0.15);
|
||||
}
|
||||
@ -36,6 +36,41 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Channel card layout - content grows, buttons at bottom */
|
||||
.card--channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.card--channel .card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card--channel .card-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card--channel .card-errors {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.card--channel .card-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Config section spacing within channel cards */
|
||||
.card--channel .channel-config-section {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Stats - Bold values, subtle labels
|
||||
=========================================== */
|
||||
@ -774,6 +809,120 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Log Level Filters - Custom styled checkboxes
|
||||
=========================================== */
|
||||
|
||||
.log-level-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.log-level-filter:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.log-level-filter__checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--input-bg, #1A1816);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.log-level-filter__checkbox::after {
|
||||
content: "✓";
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.log-level-filter input:checked ~ .log-level-filter__checkbox::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.log-level-filter__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Log level specific colors */
|
||||
.log-level-filter--trace .log-level-filter__checkbox,
|
||||
.log-level-filter--debug .log-level-filter__checkbox {
|
||||
border-color: #71717A;
|
||||
}
|
||||
|
||||
.log-level-filter--trace input:checked ~ .log-level-filter__checkbox,
|
||||
.log-level-filter--debug input:checked ~ .log-level-filter__checkbox {
|
||||
background: #71717A;
|
||||
border-color: #71717A;
|
||||
}
|
||||
|
||||
.log-level-filter--trace input:checked ~ .log-level-filter__checkbox::after,
|
||||
.log-level-filter--debug input:checked ~ .log-level-filter__checkbox::after {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-level-filter--info .log-level-filter__checkbox {
|
||||
border-color: #3B82F6;
|
||||
}
|
||||
|
||||
.log-level-filter--info input:checked ~ .log-level-filter__checkbox {
|
||||
background: #3B82F6;
|
||||
border-color: #3B82F6;
|
||||
}
|
||||
|
||||
.log-level-filter--info input:checked ~ .log-level-filter__checkbox::after {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-level-filter--warn .log-level-filter__checkbox {
|
||||
border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.log-level-filter--warn input:checked ~ .log-level-filter__checkbox {
|
||||
background: #F59E0B;
|
||||
border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.log-level-filter--warn input:checked ~ .log-level-filter__checkbox::after {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-level-filter--error .log-level-filter__checkbox,
|
||||
.log-level-filter--fatal .log-level-filter__checkbox {
|
||||
border-color: #EF4444;
|
||||
}
|
||||
|
||||
.log-level-filter--error input:checked ~ .log-level-filter__checkbox,
|
||||
.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox {
|
||||
background: #EF4444;
|
||||
border-color: #EF4444;
|
||||
}
|
||||
|
||||
.log-level-filter--error input:checked ~ .log-level-filter__checkbox::after,
|
||||
.log-level-filter--fatal input:checked ~ .log-level-filter__checkbox::after {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Log Stream
|
||||
=========================================== */
|
||||
@ -1035,8 +1184,8 @@
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-line.user .chat-bubble {
|
||||
border-color: rgba(234, 88, 12, 0.2);
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
border-color: rgba(255, 140, 66, 0.25);
|
||||
background: rgba(255, 140, 66, 0.12);
|
||||
}
|
||||
|
||||
.chat-line.assistant .chat-bubble {
|
||||
@ -1291,6 +1440,95 @@
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Light Mode - Custom Components
|
||||
=========================================== */
|
||||
|
||||
/* Log level filters */
|
||||
:root[data-theme="light"] .log-level-filter {
|
||||
background: white;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .log-level-filter:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .log-level-filter__checkbox {
|
||||
background: white;
|
||||
border-color: var(--input-border);
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
:root[data-theme="light"] .dropdown-menu {
|
||||
background: white;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .dropdown-menu__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* UI Components */
|
||||
:root[data-theme="light"] ui-input input,
|
||||
:root[data-theme="light"] ui-textarea textarea {
|
||||
background: white;
|
||||
border-color: var(--input-border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-input input:focus,
|
||||
:root[data-theme="light"] ui-textarea textarea:focus {
|
||||
background: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-checkbox .checkbox {
|
||||
background: white;
|
||||
border-color: var(--input-border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-button.variant-secondary {
|
||||
background: white;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-button.variant-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-button.variant-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-button.variant-outline:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-button.variant-ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Card components */
|
||||
:root[data-theme="light"] ui-card .card {
|
||||
background: white;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-card .card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-card .card-header {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] ui-card .card-footer {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.chat-stamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
|
||||
@ -613,36 +613,38 @@
|
||||
|
||||
.cfg-fields {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cfg-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cfg-field--error {
|
||||
padding: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--danger-subtle);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.cfg-field__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.cfg-field__help {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cfg-field__error {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Text Input */
|
||||
@ -651,66 +653,7 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cfg-input {
|
||||
flex: 1;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-accent);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-input::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cfg-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-input {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-input:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-input--sm {
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cfg-input__reset {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-input__reset:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cfg-input__reset:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Textarea */
|
||||
|
||||
/* Textarea */
|
||||
.cfg-textarea {
|
||||
@ -743,135 +686,11 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Number Input */
|
||||
.cfg-number {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-number__btn {
|
||||
width: 44px;
|
||||
border: none;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-number__btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-number__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number__btn {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.cfg-number__input {
|
||||
width: 85px;
|
||||
padding: 11px;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.cfg-number__input::-webkit-outer-spin-button,
|
||||
.cfg-number__input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Select */
|
||||
.cfg-select {
|
||||
padding: 11px 40px 11px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-accent);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-select {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Segmented Control */
|
||||
.cfg-segmented {
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-segmented {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-segmented__btn {
|
||||
padding: 9px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-segmented__btn:hover:not(:disabled):not(.active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cfg-segmented__btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.cfg-segmented__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Toggle Row */
|
||||
.cfg-toggle-row {
|
||||
@ -879,28 +698,31 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
border-color var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease;
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.cfg-toggle-row:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.cfg-toggle-row.disabled {
|
||||
opacity: 0.55;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle-row {
|
||||
background: white;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
|
||||
@ -914,77 +736,21 @@
|
||||
|
||||
.cfg-toggle-row__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.cfg-toggle-row__help {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.cfg-toggle {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cfg-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cfg-toggle__track {
|
||||
display: block;
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
position: relative;
|
||||
transition:
|
||||
background var(--duration-normal) ease,
|
||||
border-color var(--duration-normal) ease;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle__track {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.cfg-toggle__track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--text);
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition:
|
||||
transform var(--duration-normal) var(--ease-out),
|
||||
background var(--duration-normal) ease;
|
||||
}
|
||||
|
||||
.cfg-toggle input:checked + .cfg-toggle__track {
|
||||
background: var(--ok-subtle);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.cfg-toggle input:checked + .cfg-toggle__track::after {
|
||||
transform: translateX(22px);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.cfg-toggle input:focus + .cfg-toggle__track {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Object (collapsible) */
|
||||
.cfg-object {
|
||||
@ -1090,39 +856,7 @@
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-array__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-array__add:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-array__add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cfg-array__add-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cfg-array__add-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cfg-array__help {
|
||||
padding: 12px 18px;
|
||||
@ -1169,36 +903,7 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cfg-array__item-remove {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-array__item-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cfg-array__item-remove:hover:not(:disabled) {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cfg-array__item-remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cfg-array__item-content {
|
||||
padding: 18px;
|
||||
@ -1231,35 +936,6 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cfg-map__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-map__add:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-map__add-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cfg-map__add-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cfg-map__empty {
|
||||
padding: 28px 18px;
|
||||
text-align: center;
|
||||
@ -1288,32 +964,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cfg-map__item-remove {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.cfg-map__item-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cfg-map__item-remove:hover:not(:disabled) {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Pill variants */
|
||||
.pill--sm {
|
||||
padding: 5px 12px;
|
||||
@ -1337,6 +987,8 @@
|
||||
@media (max-width: 768px) {
|
||||
.config-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
margin: -8px;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
@ -1345,22 +997,53 @@
|
||||
}
|
||||
|
||||
.config-sidebar__header {
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.config-sidebar__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Search - better mobile sizing */
|
||||
.config-search {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.config-search__input {
|
||||
padding: 10px 36px 10px 40px;
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
}
|
||||
|
||||
.config-search__icon {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.config-search__clear {
|
||||
right: 18px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Horizontal scrolling nav */
|
||||
.config-nav {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.config-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-nav__item {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 14px;
|
||||
padding: 10px 14px;
|
||||
white-space: nowrap;
|
||||
min-height: 44px; /* Touch target */
|
||||
}
|
||||
|
||||
.config-nav__label {
|
||||
@ -1368,12 +1051,25 @@
|
||||
}
|
||||
|
||||
.config-sidebar__footer {
|
||||
display: none;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.config-mode-toggle {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.config-mode-toggle__btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
min-height: 40px; /* Touch target */
|
||||
}
|
||||
|
||||
/* Actions bar - stack on mobile */
|
||||
.config-actions {
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.config-actions__left,
|
||||
@ -1382,30 +1078,122 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-actions__right {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-changes-badge {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Section hero - compact */
|
||||
.config-section-hero {
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.config-section-hero__icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.config-section-hero__title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.config-section-hero__desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Subnav - horizontal scroll */
|
||||
.config-subnav {
|
||||
padding: 10px 16px 12px;
|
||||
padding: 10px 14px 12px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.config-subnav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-subnav__item {
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
min-height: 38px; /* Touch target */
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.config-content {
|
||||
padding: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* Section cards */
|
||||
.config-section-card {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.config-section-card__header {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.config-section-card__content {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.cfg-toggle-row {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.config-section-card__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.config-section-card__title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.config-section-card__desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-section-card__content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Form fields - better mobile spacing */
|
||||
.cfg-fields {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cfg-field__label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cfg-field__help {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Toggle rows - larger touch targets */
|
||||
.cfg-toggle-row {
|
||||
padding: 14px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.cfg-toggle-row__label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cfg-toggle-row__help {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Textarea - better mobile sizing */
|
||||
.cfg-textarea {
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-raw-field textarea {
|
||||
min-height: 400px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Map items - stack on mobile */
|
||||
.cfg-map__item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
@ -1414,34 +1202,142 @@
|
||||
.cfg-map__item-remove {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Object/Array - better mobile spacing */
|
||||
.cfg-object__header,
|
||||
.cfg-array__header {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.cfg-object__content,
|
||||
.cfg-array__item-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* Diff panel */
|
||||
.config-diff {
|
||||
margin: 12px 14px 0;
|
||||
}
|
||||
|
||||
.config-diff__summary {
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-diff__content {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.config-diff__item {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-diff__path {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-diff__values {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.config-layout {
|
||||
margin: -4px;
|
||||
}
|
||||
|
||||
.config-sidebar__header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.config-search {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.config-nav {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Icon-only nav on small screens */
|
||||
.config-nav__icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 17px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.config-nav__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-nav__item {
|
||||
padding: 10px;
|
||||
min-width: 44px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.config-actions__right {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-section-hero {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.config-section-hero__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.config-section-hero__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-section-hero__desc {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-subnav {
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.config-section-card__header {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.config-section-card__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.config-section-card__title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cfg-segmented {
|
||||
flex-wrap: wrap;
|
||||
.config-section-card__content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.cfg-segmented__btn {
|
||||
flex: 1 0 auto;
|
||||
min-width: 70px;
|
||||
.cfg-toggle-row {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.config-diff {
|
||||
margin: 10px 12px 0;
|
||||
}
|
||||
|
||||
.config-raw-field textarea {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
@ -164,6 +164,33 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Health badge - matches theme toggle size */
|
||||
.health-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--secondary);
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.health-badge__status {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-status ui-badge {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topbar-status ui-badge .statusDot {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -197,6 +224,7 @@
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
transition:
|
||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
@ -206,7 +234,8 @@
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
display: none;
|
||||
/* Chrome/Safari */
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
@ -277,12 +306,16 @@
|
||||
/* Nav groups */
|
||||
.nav-group {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
@ -417,7 +450,7 @@
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 12px 16px 32px;
|
||||
padding: 32px 16px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
@ -442,14 +475,15 @@
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
overflow: visible;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
transform var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 80px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
@ -489,7 +523,7 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content--chat .content-header > div:first-child {
|
||||
.content--chat .content-header>div:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@ -606,4 +640,4 @@
|
||||
.list-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Hide nav toggle button on mobile (sidebar is always visible/stacked) */
|
||||
.nav-collapse-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@ -80,6 +85,43 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Health badge - compact for mobile */
|
||||
.health-badge {
|
||||
padding: 3px 8px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.health-badge span:nth-child(2) {
|
||||
display: none; /* Hide "Health" text on mobile */
|
||||
}
|
||||
|
||||
.health-badge__status {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.health-badge .statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Theme controls - compact */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
theme-selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
@ -214,9 +256,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Chat thread - ensure proper scrolling on mobile */
|
||||
.chat-thread {
|
||||
margin-top: 8px;
|
||||
padding: 12px 8px;
|
||||
padding: 12px 8px 80px 8px; /* Extra bottom padding for compose area */
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
@ -228,15 +274,47 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Chat compose - fixed at bottom on mobile */
|
||||
.chat-compose {
|
||||
position: fixed !important;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 12px 8px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
z-index: 50;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: var(--bg-content);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.shell--chat .chat-compose {
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 60px;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-compose__actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Ensure content area accounts for fixed compose */
|
||||
.shell--chat .content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Log stream */
|
||||
@ -315,6 +393,35 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Health badge - ultra compact */
|
||||
.health-badge {
|
||||
padding: 2px 6px;
|
||||
height: 26px;
|
||||
font-size: 10px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.health-badge__status {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.health-badge .statusDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Theme controls - ultra compact */
|
||||
.theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
@ -345,10 +452,16 @@
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
padding: 10px 6px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 52px;
|
||||
min-height: 40px;
|
||||
max-height: 100px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.btn {
|
||||
@ -372,3 +485,10 @@
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure chat content has proper spacing for fixed compose */
|
||||
@media (max-width: 600px) {
|
||||
.shell--chat .content {
|
||||
padding-bottom: 80px; /* Space for fixed compose area */
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
syncTabWithLocation,
|
||||
syncThemeWithSettings,
|
||||
} from "./app-settings";
|
||||
import { applyColorTheme } from "./theme";
|
||||
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||
import {
|
||||
startLogsPolling,
|
||||
@ -31,6 +32,7 @@ type LifecycleHost = {
|
||||
logsEntries: unknown[];
|
||||
popStateHandler: () => void;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
settings: { colorTheme: string };
|
||||
};
|
||||
|
||||
export function handleConnected(host: LifecycleHost) {
|
||||
@ -42,6 +44,10 @@ export function handleConnected(host: LifecycleHost) {
|
||||
syncThemeWithSettings(
|
||||
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
||||
);
|
||||
// Apply saved color theme
|
||||
if (host.settings?.colorTheme) {
|
||||
applyColorTheme(host.settings.colorTheme as any);
|
||||
}
|
||||
attachThemeListener(
|
||||
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||
);
|
||||
|
||||
@ -79,6 +79,10 @@ import {
|
||||
saveExecApprovals,
|
||||
updateExecApprovalsFormValue,
|
||||
} from "./controllers/exec-approvals";
|
||||
import "./components/badge";
|
||||
import "./components/button";
|
||||
import "./components/checkbox";
|
||||
import "./components/theme-selector";
|
||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
@ -119,10 +123,10 @@ export function renderApp(state: AppViewState) {
|
||||
<button
|
||||
class="nav-collapse-toggle"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
>
|
||||
@ -139,30 +143,41 @@ export function renderApp(state: AppViewState) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-status">
|
||||
<div class="pill">
|
||||
<div class="health-badge">
|
||||
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
||||
<span>Health</span>
|
||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
||||
<span class="health-badge__status">${state.connected ? "OK" : "Offline"}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
${renderThemeToggle(state)}
|
||||
<theme-selector
|
||||
.colorTheme=${state.settings.colorTheme}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const host = state as unknown as { setColorTheme: (theme: string) => void };
|
||||
if (host.setColorTheme) {
|
||||
host.setColorTheme(e.detail.colorTheme);
|
||||
}
|
||||
}}
|
||||
></theme-selector>
|
||||
</div>
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||
${TAB_GROUPS.map((group) => {
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||
<button
|
||||
class="nav-label"
|
||||
@click=${() => {
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
aria-expanded=${!isGroupCollapsed}
|
||||
>
|
||||
<span class="nav-label__text">${group.label}</span>
|
||||
@ -173,7 +188,7 @@ export function renderApp(state: AppViewState) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
})}
|
||||
<div class="nav-group nav-group--links">
|
||||
<div class="nav-label nav-label--static">
|
||||
<span class="nav-label__text">Resources</span>
|
||||
@ -200,383 +215,383 @@ export function renderApp(state: AppViewState) {
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError
|
||||
? html`<div class="pill danger">${state.lastError}</div>`
|
||||
: nothing}
|
||||
? html`<ui-badge variant="destructive">${state.lastError}</ui-badge>`
|
||||
: nothing}
|
||||
${isChat ? renderChatControls(state) : nothing}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${state.tab === "overview"
|
||||
? renderOverview({
|
||||
connected: state.connected,
|
||||
hello: state.hello,
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
})
|
||||
: nothing}
|
||||
? renderOverview({
|
||||
connected: state.connected,
|
||||
hello: state.hello,
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "channels"
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
lastError: state.channelsError,
|
||||
lastSuccessAt: state.channelsLastSuccess,
|
||||
whatsappMessage: state.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||
onRefresh: (probe) => loadChannels(state, probe),
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
state.handleNostrProfileEdit(accountId, profile),
|
||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||
onNostrProfileFieldChange: (field, value) =>
|
||||
state.handleNostrProfileFieldChange(field, value),
|
||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||
})
|
||||
: nothing}
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
lastError: state.channelsError,
|
||||
lastSuccessAt: state.channelsLastSuccess,
|
||||
whatsappMessage: state.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||
onRefresh: (probe) => loadChannels(state, probe),
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
state.handleNostrProfileEdit(accountId, profile),
|
||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||
onNostrProfileFieldChange: (field, value) =>
|
||||
state.handleNostrProfileFieldChange(field, value),
|
||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "instances"
|
||||
? renderInstances({
|
||||
loading: state.presenceLoading,
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderInstances({
|
||||
loading: state.presenceLoading,
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "sessions"
|
||||
? renderSessions({
|
||||
loading: state.sessionsLoading,
|
||||
result: state.sessionsResult,
|
||||
error: state.sessionsError,
|
||||
activeMinutes: state.sessionsFilterActive,
|
||||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSession(state, key),
|
||||
})
|
||||
: nothing}
|
||||
? renderSessions({
|
||||
loading: state.sessionsLoading,
|
||||
result: state.sessionsResult,
|
||||
error: state.sessionsError,
|
||||
activeMinutes: state.sessionsFilterActive,
|
||||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSession(state, key),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "cron"
|
||||
? renderCron({
|
||||
loading: state.cronLoading,
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
error: state.cronError,
|
||||
busy: state.cronBusy,
|
||||
form: state.cronForm,
|
||||
channels: state.channelsSnapshot?.channelMeta?.length
|
||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||
: state.channelsSnapshot?.channelOrder ?? [],
|
||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||
runsJobId: state.cronRunsJobId,
|
||||
runs: state.cronRuns,
|
||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||
onRefresh: () => state.loadCron(),
|
||||
onAdd: () => addCronJob(state),
|
||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||
onRun: (job) => runCronJob(state, job),
|
||||
onRemove: (job) => removeCronJob(state, job),
|
||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||
})
|
||||
: nothing}
|
||||
? renderCron({
|
||||
loading: state.cronLoading,
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
error: state.cronError,
|
||||
busy: state.cronBusy,
|
||||
form: state.cronForm,
|
||||
channels: state.channelsSnapshot?.channelMeta?.length
|
||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||
: state.channelsSnapshot?.channelOrder ?? [],
|
||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||
runsJobId: state.cronRunsJobId,
|
||||
runs: state.cronRuns,
|
||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||
onRefresh: () => state.loadCron(),
|
||||
onAdd: () => addCronJob(state),
|
||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||
onRun: (job) => runCronJob(state, job),
|
||||
onRemove: (job) => removeCronJob(state, job),
|
||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "skills"
|
||||
? renderSkills({
|
||||
loading: state.skillsLoading,
|
||||
report: state.skillsReport,
|
||||
error: state.skillsError,
|
||||
filter: state.skillsFilter,
|
||||
edits: state.skillEdits,
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||
onInstall: (skillKey, name, installId) =>
|
||||
installSkill(state, skillKey, name, installId),
|
||||
})
|
||||
: nothing}
|
||||
? renderSkills({
|
||||
loading: state.skillsLoading,
|
||||
report: state.skillsReport,
|
||||
error: state.skillsError,
|
||||
filter: state.skillsFilter,
|
||||
edits: state.skillEdits,
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||
onInstall: (skillKey, name, installId) =>
|
||||
installSkill(state, skillKey, name, installId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "nodes"
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
configFormMode: state.configFormMode,
|
||||
execApprovalsLoading: state.execApprovalsLoading,
|
||||
execApprovalsSaving: state.execApprovalsSaving,
|
||||
execApprovalsDirty: state.execApprovalsDirty,
|
||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||
execApprovalsForm: state.execApprovalsForm,
|
||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return loadExecApprovals(state, target);
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
state.execApprovalsSnapshot = null;
|
||||
state.execApprovalsForm = null;
|
||||
state.execApprovalsDirty = false;
|
||||
state.execApprovalsSelectedAgent = null;
|
||||
},
|
||||
onExecApprovalsSelectAgent: (agentId) => {
|
||||
state.execApprovalsSelectedAgent = agentId;
|
||||
},
|
||||
onExecApprovalsPatch: (path, value) =>
|
||||
updateExecApprovalsFormValue(state, path, value),
|
||||
onExecApprovalsRemove: (path) =>
|
||||
removeExecApprovalsFormValue(state, path),
|
||||
onSaveExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return saveExecApprovals(state, target);
|
||||
},
|
||||
})
|
||||
: nothing}
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
configFormMode: state.configFormMode,
|
||||
execApprovalsLoading: state.execApprovalsLoading,
|
||||
execApprovalsSaving: state.execApprovalsSaving,
|
||||
execApprovalsDirty: state.execApprovalsDirty,
|
||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||
execApprovalsForm: state.execApprovalsForm,
|
||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return loadExecApprovals(state, target);
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
state.execApprovalsSnapshot = null;
|
||||
state.execApprovalsForm = null;
|
||||
state.execApprovalsDirty = false;
|
||||
state.execApprovalsSelectedAgent = null;
|
||||
},
|
||||
onExecApprovalsSelectAgent: (agentId) => {
|
||||
state.execApprovalsSelectedAgent = agentId;
|
||||
},
|
||||
onExecApprovalsPatch: (path, value) =>
|
||||
updateExecApprovalsFormValue(state, path, value),
|
||||
onExecApprovalsRemove: (path) =>
|
||||
removeExecApprovalsFormValue(state, path),
|
||||
onSaveExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return saveExecApprovals(state, target);
|
||||
},
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "chat"
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
state.handleSendChat("/new", { restoreDraft: true }),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onNewSession: () =>
|
||||
state.handleSendChat("/new", { restoreDraft: true }),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "config"
|
||||
? renderConfig({
|
||||
raw: state.configRaw,
|
||||
originalRaw: state.configRawOriginal,
|
||||
valid: state.configValid,
|
||||
issues: state.configIssues,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
applying: state.configApplying,
|
||||
updating: state.updateRunning,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
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;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderConfig({
|
||||
raw: state.configRaw,
|
||||
originalRaw: state.configRawOriginal,
|
||||
valid: state.configValid,
|
||||
issues: state.configIssues,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
applying: state.configApplying,
|
||||
updating: state.updateRunning,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
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;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "debug"
|
||||
? renderDebug({
|
||||
loading: state.debugLoading,
|
||||
status: state.debugStatus,
|
||||
health: state.debugHealth,
|
||||
models: state.debugModels,
|
||||
heartbeat: state.debugHeartbeat,
|
||||
eventLog: state.eventLog,
|
||||
callMethod: state.debugCallMethod,
|
||||
callParams: state.debugCallParams,
|
||||
callResult: state.debugCallResult,
|
||||
callError: state.debugCallError,
|
||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||
onRefresh: () => loadDebug(state),
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderDebug({
|
||||
loading: state.debugLoading,
|
||||
status: state.debugStatus,
|
||||
health: state.debugHealth,
|
||||
models: state.debugModels,
|
||||
heartbeat: state.debugHeartbeat,
|
||||
eventLog: state.eventLog,
|
||||
callMethod: state.debugCallMethod,
|
||||
callParams: state.debugCallParams,
|
||||
callResult: state.debugCallResult,
|
||||
callError: state.debugCallError,
|
||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||
onRefresh: () => loadDebug(state),
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "logs"
|
||||
? renderLogs({
|
||||
loading: state.logsLoading,
|
||||
error: state.logsError,
|
||||
file: state.logsFile,
|
||||
entries: state.logsEntries,
|
||||
filterText: state.logsFilterText,
|
||||
levelFilters: state.logsLevelFilters,
|
||||
autoFollow: state.logsAutoFollow,
|
||||
truncated: state.logsTruncated,
|
||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||
onLevelToggle: (level, enabled) => {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing}
|
||||
? renderLogs({
|
||||
loading: state.logsLoading,
|
||||
error: state.logsError,
|
||||
file: state.logsFile,
|
||||
entries: state.logsEntries,
|
||||
filterText: state.logsFilterText,
|
||||
levelFilters: state.logsLevelFilters,
|
||||
autoFollow: state.logsAutoFollow,
|
||||
truncated: state.logsTruncated,
|
||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||
onLevelToggle: (level, enabled) => {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing}
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
${renderGatewayUrlConfirmation(state)}
|
||||
|
||||
@ -200,6 +200,16 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
root.style.colorScheme = resolved;
|
||||
|
||||
// Update meta theme-color for mobile browsers (muted backgrounds)
|
||||
let metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
if (!metaThemeColor) {
|
||||
metaThemeColor = document.createElement("meta");
|
||||
metaThemeColor.setAttribute("name", "theme-color");
|
||||
document.head.appendChild(metaThemeColor);
|
||||
}
|
||||
const color = resolved === "light" ? "#FAFAF9" : "#1A1816";
|
||||
metaThemeColor.setAttribute("content", color);
|
||||
}
|
||||
|
||||
export function attachThemeListener(host: SettingsHost) {
|
||||
|
||||
@ -351,6 +351,15 @@ export class MoltbotApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
setColorTheme(colorTheme: string) {
|
||||
import("./theme").then(({ applyColorTheme, saveColorTheme }) => {
|
||||
applyColorTheme(colorTheme as any);
|
||||
saveColorTheme(colorTheme as any);
|
||||
this.settings = { ...this.settings, colorTheme: colorTheme as any };
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(
|
||||
this as unknown as Parameters<typeof loadOverviewInternal>[0],
|
||||
|
||||
108
ui/src/ui/components/badge.ts
Normal file
108
ui/src/ui/components/badge.ts
Normal file
@ -0,0 +1,108 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
export type BadgeVariant = "default" | "secondary" | "outline" | "destructive" | "success" | "warning";
|
||||
|
||||
@customElement("ui-badge")
|
||||
export class UiBadge extends LitElement {
|
||||
@property({ type: String }) variant: BadgeVariant = "default";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
transition: colors 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.variant-default {
|
||||
border-color: transparent;
|
||||
background-color: var(--accent, #FF9F40);
|
||||
color: var(--primary-foreground, #fff);
|
||||
}
|
||||
|
||||
.variant-secondary {
|
||||
border-color: transparent;
|
||||
background-color: var(--secondary, #2A2723);
|
||||
color: var(--secondary-foreground, #EDE9E6);
|
||||
}
|
||||
|
||||
.variant-outline {
|
||||
border-color: var(--border, #3E3B38);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
}
|
||||
|
||||
.variant-destructive {
|
||||
border-color: transparent;
|
||||
background-color: var(--danger, #EF4444);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.variant-success {
|
||||
border-color: transparent;
|
||||
background-color: var(--ok, #22C55E);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.variant-warning {
|
||||
border-color: transparent;
|
||||
background-color: var(--warn, #F59E0B);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) .variant-default {
|
||||
background-color: var(--accent, #FF8C42);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-secondary {
|
||||
background-color: var(--secondary, #F5F4F2);
|
||||
color: var(--secondary-foreground, #3f3f46);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-outline {
|
||||
border-color: var(--border, #e4e4e7);
|
||||
color: var(--foreground, #1A1410);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-destructive {
|
||||
background-color: var(--danger, #D32F2F);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-success {
|
||||
background-color: var(--ok, #2E7D32);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-warning {
|
||||
background-color: var(--warn, #F57C00);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class=${classMap({ badge: true, [`variant-${this.variant}`]: true })}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-badge": UiBadge;
|
||||
}
|
||||
}
|
||||
206
ui/src/ui/components/button.ts
Normal file
206
ui/src/ui/components/button.ts
Normal file
@ -0,0 +1,206 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import "./icon";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
|
||||
export type ButtonSize = "sm" | "md" | "lg" | "icon";
|
||||
|
||||
@customElement("ui-button")
|
||||
export class UiButton extends LitElement {
|
||||
@property({ type: String }) variant: ButtonVariant = "secondary";
|
||||
@property({ type: String }) size: ButtonSize = "md";
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: String }) icon?: string;
|
||||
@property({ type: String }) href?: string;
|
||||
@property({ type: String }) target?: string;
|
||||
@property({ type: String }) type: "button" | "submit" | "reset" = "button";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent, #FF9F40);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.size-sm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.size-md {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.size-lg {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.size-icon {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
.size-icon.size-sm {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.variant-primary {
|
||||
background-color: var(--accent, #FF9F40);
|
||||
color: var(--primary-foreground, #fff);
|
||||
border-color: var(--accent, #FF9F40);
|
||||
}
|
||||
.variant-primary:hover {
|
||||
background-color: var(--accent-hover, #F97316);
|
||||
border-color: var(--accent-hover, #F97316);
|
||||
}
|
||||
|
||||
.variant-secondary {
|
||||
background-color: var(--secondary, #2A2723);
|
||||
color: var(--secondary-foreground, #EDE9E6);
|
||||
border-color: var(--border, #3E3B38);
|
||||
}
|
||||
.variant-secondary:hover {
|
||||
background-color: var(--secondary-hover, #3E3B38);
|
||||
border-color: var(--border-strong, #57534E);
|
||||
}
|
||||
|
||||
.variant-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--border, #3E3B38);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
}
|
||||
.variant-outline:hover {
|
||||
background-color: var(--secondary, #2A2723);
|
||||
border-color: var(--border-strong, #57534E);
|
||||
}
|
||||
|
||||
.variant-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
}
|
||||
.variant-ghost:hover {
|
||||
background-color: var(--secondary, #2A2723);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
}
|
||||
|
||||
.variant-danger {
|
||||
background-color: var(--danger, #EF4444);
|
||||
color: white;
|
||||
}
|
||||
.variant-danger:hover {
|
||||
background-color: var(--danger-hover, #DC2626);
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
:host-context([data-theme="light"]) .variant-secondary {
|
||||
background-color: white;
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
:host-context([data-theme="light"]) .variant-secondary:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-outline {
|
||||
color: var(--foreground);
|
||||
}
|
||||
:host-context([data-theme="light"]) .variant-outline:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .variant-ghost:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
const classes = {
|
||||
btn: true,
|
||||
[`variant-${this.variant}`]: true,
|
||||
[`size-${this.size}`]: true,
|
||||
loading: this.loading,
|
||||
};
|
||||
|
||||
const content = html`
|
||||
${this.loading
|
||||
? html`<ui-icon name="loader" class="spinner" .size=${this.size === "sm" ? 14 : 16}></ui-icon>`
|
||||
: this.icon
|
||||
? html`<ui-icon .name=${this.icon} .size=${this.size === "sm" ? 14 : 16}></ui-icon>`
|
||||
: null}
|
||||
<slot></slot>
|
||||
`;
|
||||
|
||||
if (this.href) {
|
||||
return html`
|
||||
<a
|
||||
class=${classMap(classes)}
|
||||
href=${this.href}
|
||||
target=${this.target || "_self"}
|
||||
?disabled=${this.disabled}
|
||||
>
|
||||
${content}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class=${classMap(classes)}
|
||||
type=${this.type}
|
||||
?disabled=${this.disabled || this.loading}
|
||||
>
|
||||
${content}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-button": UiButton;
|
||||
}
|
||||
}
|
||||
110
ui/src/ui/components/card.ts
Normal file
110
ui/src/ui/components/card.ts
Normal file
@ -0,0 +1,110 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
@customElement("ui-card")
|
||||
export class UiCard extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border, #3E3B38);
|
||||
background: var(--card, #1A1816);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.1));
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-strong, #57534E);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border, #3E3B38);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground, #EDE9E6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border, #3E3B38);
|
||||
background: var(--bg-muted, #1F1D1B);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) .card {
|
||||
background: var(--card, #FFFFFF);
|
||||
border-color: var(--border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .card:hover {
|
||||
border-color: var(--border-strong, #d4d4d8);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .card-header {
|
||||
border-bottom-color: var(--border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .card-title {
|
||||
color: var(--foreground, #1A1410);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .card-desc {
|
||||
color: var(--muted-foreground, #71717a);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .card-footer {
|
||||
border-top-color: var(--border, #e4e4e7);
|
||||
background: var(--bg-muted, #EFEEE9);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-card": UiCard;
|
||||
}
|
||||
}
|
||||
106
ui/src/ui/components/checkbox.ts
Normal file
106
ui/src/ui/components/checkbox.ts
Normal file
@ -0,0 +1,106 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import "./icon";
|
||||
|
||||
@customElement("ui-checkbox")
|
||||
export class UiCheckbox extends LitElement {
|
||||
@property({ type: Boolean }) checked = false;
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property({ type: String }) label = "";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wrapper.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--input-bg, #1A1816);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wrapper:hover .checkbox {
|
||||
border-color: var(--accent, #FF9F40);
|
||||
background: rgba(255, 159, 64, 0.1);
|
||||
}
|
||||
|
||||
.wrapper.checked .checkbox {
|
||||
background: var(--accent, #FF9F40);
|
||||
border-color: var(--accent, #FF9F40);
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) .checkbox {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .wrapper:hover .checkbox {
|
||||
background: rgba(255, 159, 64, 0.1);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .wrapper.checked .checkbox {
|
||||
background: var(--accent, #FF9F40);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--foreground, #EDE9E6);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .label {
|
||||
color: var(--foreground, #1A1410);
|
||||
}
|
||||
`;
|
||||
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.checked = !this.checked;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { checked: this.checked },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="wrapper ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
@click=${this.toggle}
|
||||
>
|
||||
<div class="checkbox">
|
||||
${this.checked ? html`<ui-icon name="check" .size=${12} strokeWidth="3"></ui-icon>` : null}
|
||||
</div>
|
||||
${this.label ? html`<span class="label">${this.label}</span>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-checkbox": UiCheckbox;
|
||||
}
|
||||
}
|
||||
200
ui/src/ui/components/dropdown-menu.ts
Normal file
200
ui/src/ui/components/dropdown-menu.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import "./icon";
|
||||
|
||||
export interface DropdownMenuItem {
|
||||
label: string;
|
||||
value: string;
|
||||
variant?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ui-dropdown-menu")
|
||||
export class UiDropdownMenu extends LitElement {
|
||||
@property({ type: Array }) items: DropdownMenuItem[] = [];
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@state() private open = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border, #3E3B38);
|
||||
background: var(--bg-elevated, #1F1D1B);
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text, #EDE9E6);
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #2A2826);
|
||||
border-color: var(--border-strong, #57534E);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--card, #1A1816);
|
||||
border: 1px solid var(--border, #3E3B38);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
color: var(--text, #EDE9E6);
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #2A2826);
|
||||
}
|
||||
|
||||
.menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: var(--danger, #EF4444);
|
||||
}
|
||||
|
||||
.menu-item.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) .trigger {
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .trigger:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .menu {
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .menu-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
private handleClickOutside = (e: MouseEvent) => {
|
||||
if (!this.contains(e.target as Node)) {
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
private toggleMenu(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (!this.disabled) {
|
||||
this.open = !this.open;
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemClick(item: DropdownMenuItem, e: Event) {
|
||||
e.stopPropagation();
|
||||
if (!item.disabled) {
|
||||
this.open = false;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: { value: item.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button
|
||||
class="trigger"
|
||||
@click=${this.toggleMenu}
|
||||
?disabled=${this.disabled}
|
||||
aria-haspopup="true"
|
||||
aria-expanded=${this.open}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="12" cy="5" r="1"/>
|
||||
<circle cx="12" cy="19" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu ${this.open ? "open" : ""}">
|
||||
${this.items.map(
|
||||
(item) => html`
|
||||
<button
|
||||
class="menu-item ${item.variant === "danger" ? "danger" : ""}"
|
||||
@click=${(e: Event) => this.handleItemClick(item, e)}
|
||||
?disabled=${item.disabled}
|
||||
>
|
||||
${item.label}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-dropdown-menu": UiDropdownMenu;
|
||||
}
|
||||
}
|
||||
51
ui/src/ui/components/icon.ts
Normal file
51
ui/src/ui/components/icon.ts
Normal file
@ -0,0 +1,51 @@
|
||||
|
||||
import { LitElement, html, css, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { icons, type IconName } from "../icons";
|
||||
|
||||
@customElement("ui-icon")
|
||||
export class UiIcon extends LitElement {
|
||||
@property({ type: String }) name?: IconName;
|
||||
@property({ type: Number }) size = 16;
|
||||
@property({ type: String }) color = "currentColor";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
const iconTemplate = this.name ? icons[this.name] : undefined;
|
||||
if (!iconTemplate) return html``;
|
||||
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
width: ${this.size}px;
|
||||
height: ${this.size}px;
|
||||
color: ${this.color};
|
||||
}
|
||||
</style>
|
||||
${iconTemplate}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-icon": UiIcon;
|
||||
}
|
||||
}
|
||||
14
ui/src/ui/components/index.ts
Normal file
14
ui/src/ui/components/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
export * from "./icon";
|
||||
export * from "./button";
|
||||
export * from "./badge";
|
||||
export * from "./card";
|
||||
export * from "./input";
|
||||
export * from "./select";
|
||||
export * from "./switch";
|
||||
export * from "./tooltip";
|
||||
export * from "./segmented-control";
|
||||
export * from "./checkbox";
|
||||
export * from "./resizable-divider";
|
||||
export * from "./textarea";
|
||||
export * from "./theme-selector";
|
||||
148
ui/src/ui/components/input.ts
Normal file
148
ui/src/ui/components/input.ts
Normal file
@ -0,0 +1,148 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import "./icon";
|
||||
|
||||
@customElement("ui-input")
|
||||
export class UiInput extends LitElement {
|
||||
@property({ type: String }) type = "text";
|
||||
@property({ type: String }) placeholder = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: String }) label = "";
|
||||
@property({ type: String }) error = "";
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property({ type: String }) icon?: string;
|
||||
|
||||
@query("input") inputElement!: HTMLInputElement;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
padding-left: var(--input-padding-left, 12px);
|
||||
background: var(--input-bg, #1A1816);
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent, #FF9F40);
|
||||
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) input {
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) input:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.has-icon input {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 12px;
|
||||
color: var(--danger, #EF4444);
|
||||
}
|
||||
|
||||
.input-error input {
|
||||
border-color: var(--danger, #EF4444);
|
||||
}
|
||||
`;
|
||||
|
||||
handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.value = target.value;
|
||||
this.dispatchEvent(new CustomEvent("input", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
handleChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.value = target.value;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="input-wrapper ${this.error ? 'input-error' : ''}">
|
||||
${this.label ? html`<label class="label">${this.label}</label>` : null}
|
||||
|
||||
<div class="input-container ${this.icon ? 'has-icon' : ''}">
|
||||
${this.icon ? html`<ui-icon class="icon" .name=${this.icon} .size=${16}></ui-icon>` : null}
|
||||
<input
|
||||
.type=${this.type}
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this.handleInput}
|
||||
@change=${this.handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-input": UiInput;
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,19 @@ export class ResizableDivider extends LitElement {
|
||||
:host(.dragging) {
|
||||
background: var(--accent, #007bff);
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) {
|
||||
background: var(--border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]):hover {
|
||||
background: var(--accent, #FF8C42);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]).dragging {
|
||||
background: var(--accent, #FF8C42);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
|
||||
98
ui/src/ui/components/segmented-control.ts
Normal file
98
ui/src/ui/components/segmented-control.ts
Normal file
@ -0,0 +1,98 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
export interface SegmentonedOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@customElement("ui-segmented-control")
|
||||
export class UiSegmentedControl extends LitElement {
|
||||
@property({ type: Array }) options: SegmentonedOption[] = [];
|
||||
@property({ type: String }) value = "";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
background: var(--input-bg, #2A2723);
|
||||
padding: 3px; // slightly smaller padding for tighter look
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px solid var(--border, #3E3B38);
|
||||
}
|
||||
|
||||
.segment {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.segment:hover {
|
||||
color: var(--foreground, #EDE9E6);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.segment.active {
|
||||
background: var(--card, #1A1816);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) {
|
||||
background: var(--input-bg, #F5F4F2);
|
||||
border-color: var(--border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .segment {
|
||||
color: var(--muted-foreground, #71717a);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .segment:hover {
|
||||
color: var(--foreground, #1A1410);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .segment.active {
|
||||
background: var(--card, #FFFFFF);
|
||||
color: var(--foreground, #1A1410);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
`;
|
||||
|
||||
select(val: string) {
|
||||
if (this.value === val) return;
|
||||
this.value = val;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
${this.options.map(opt => html`
|
||||
<div
|
||||
class="segment ${opt.value === this.value ? 'active' : ''}"
|
||||
@click=${() => this.select(opt.value)}
|
||||
>
|
||||
${opt.label}
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-segmented-control": UiSegmentedControl;
|
||||
}
|
||||
}
|
||||
124
ui/src/ui/components/select.ts
Normal file
124
ui/src/ui/components/select.ts
Normal file
@ -0,0 +1,124 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import "./icon";
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@customElement("ui-select")
|
||||
export class UiSelect extends LitElement {
|
||||
@property({ type: String }) label = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: Array }) options: SelectOption[] = [];
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
}
|
||||
|
||||
.select-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
padding-right: 36px;
|
||||
background: var(--input-bg, #1A1816);
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent, #FF9F40);
|
||||
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) select {
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) select:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
pointer-events: none;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
handleChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
this.value = target.value;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="wrapper">
|
||||
${this.label ? html`<label class="label">${this.label}</label>` : null}
|
||||
<div class="select-container">
|
||||
<select
|
||||
.value=${this.value}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this.handleChange}
|
||||
>
|
||||
${this.options.map(opt => html`
|
||||
<option value=${opt.value} ?selected=${opt.value === this.value}>${opt.label}</option>
|
||||
`)}
|
||||
</select>
|
||||
<ui-icon class="chevron" name="chevronDown" .size=${16}></ui-icon>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-select": UiSelect;
|
||||
}
|
||||
}
|
||||
100
ui/src/ui/components/switch.ts
Normal file
100
ui/src/ui/components/switch.ts
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("ui-switch")
|
||||
export class UiSwitch extends LitElement {
|
||||
@property({ type: Boolean }) checked = false;
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--input-bg, #2A2723);
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.track.checked {
|
||||
background: var(--accent, #FF9F40);
|
||||
border-color: var(--accent, #FF9F40);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #A8A29D;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.track.checked .thumb {
|
||||
background: white;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.track.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) .track {
|
||||
background: var(--input-bg, #F5F4F2);
|
||||
border-color: var(--input-border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .track.checked {
|
||||
background: var(--accent, #FF8C42);
|
||||
border-color: var(--accent, #FF8C42);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .thumb {
|
||||
background: #71717a;
|
||||
}
|
||||
`;
|
||||
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.checked = !this.checked;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { checked: this.checked },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="track ${this.checked ? 'checked' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
@click=${this.toggle}
|
||||
>
|
||||
<div class="thumb"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-switch": UiSwitch;
|
||||
}
|
||||
}
|
||||
124
ui/src/ui/components/textarea.ts
Normal file
124
ui/src/ui/components/textarea.ts
Normal file
@ -0,0 +1,124 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
|
||||
@customElement("ui-textarea")
|
||||
export class UiTextarea extends LitElement {
|
||||
@property({ type: String }) placeholder = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: String }) label = "";
|
||||
@property({ type: String }) error = "";
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property({ type: Number }) rows = 4;
|
||||
|
||||
@query("textarea") textareaElement!: HTMLTextAreaElement;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground, #A8A29D);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--input-bg, #1A1816);
|
||||
border: 1px solid var(--input-border, #3E3B38);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--foreground, #EDE9E6);
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent, #FF9F40);
|
||||
box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
|
||||
}
|
||||
|
||||
textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) textarea {
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) textarea:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 12px;
|
||||
color: var(--danger, #EF4444);
|
||||
}
|
||||
|
||||
.textarea-error textarea {
|
||||
border-color: var(--danger, #EF4444);
|
||||
}
|
||||
`;
|
||||
|
||||
handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
this.value = target.value;
|
||||
this.dispatchEvent(new CustomEvent("input", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
handleChange(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
this.value = target.value;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="textarea-wrapper ${this.error ? 'textarea-error' : ''}">
|
||||
${this.label ? html`<label class="label">${this.label}</label>` : null}
|
||||
|
||||
<textarea
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder}
|
||||
.rows=${this.rows}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this.handleInput}
|
||||
@change=${this.handleChange}
|
||||
></textarea>
|
||||
|
||||
${this.error ? html`<span class="error-msg">${this.error}</span>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-textarea": UiTextarea;
|
||||
}
|
||||
}
|
||||
213
ui/src/ui/components/theme-selector.ts
Normal file
213
ui/src/ui/components/theme-selector.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { COLOR_THEMES, type ColorTheme } from "../theme";
|
||||
import "./icon";
|
||||
|
||||
@customElement("theme-selector")
|
||||
export class ThemeSelector extends LitElement {
|
||||
@property({ type: String }) colorTheme: ColorTheme = "tangerine";
|
||||
@state() private isOpen = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 6px;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
:host-context([data-theme="light"]) .trigger {
|
||||
background: white;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .trigger:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .dropdown {
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Mobile optimization */
|
||||
@media (max-width: 600px) {
|
||||
.trigger {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.trigger {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 5px 7px;
|
||||
font-size: 11px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
}
|
||||
|
||||
private handleOutsideClick = (e: MouseEvent) => {
|
||||
if (!this.contains(e.target as Node)) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
private toggleDropdown(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
private selectTheme(theme: ColorTheme) {
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { colorTheme: theme },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button class="trigger" @click=${this.toggleDropdown} title="Change color theme">
|
||||
<ui-icon name="moreVertical" .size=${14}></ui-icon>
|
||||
</button>
|
||||
<div class="dropdown ${this.isOpen ? 'open' : ''}">
|
||||
${Object.entries(COLOR_THEMES).map(([key, { name, preview }]) => html`
|
||||
<button
|
||||
class="dropdown-item ${key === this.colorTheme ? 'active' : ''}"
|
||||
@click=${() => this.selectTheme(key as ColorTheme)}
|
||||
>
|
||||
<div class="color-preview" style="background-color: ${preview};"></div>
|
||||
<span>${name}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"theme-selector": ThemeSelector;
|
||||
}
|
||||
}
|
||||
75
ui/src/ui/components/tooltip.ts
Normal file
75
ui/src/ui/components/tooltip.ts
Normal file
@ -0,0 +1,75 @@
|
||||
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("ui-tooltip")
|
||||
export class UiTooltip extends LitElement {
|
||||
@property({ type: String }) content = "";
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||
background: var(--tooltip-bg, #1A1816);
|
||||
color: var(--tooltip-fg, #EDE9E6);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--border, #3E3B38);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Arrow */
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
border-color: var(--tooltip-bg, #1A1816) transparent transparent transparent;
|
||||
}
|
||||
|
||||
:host(:hover) .tooltip {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-8px) scale(1);
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
:host-context([data-theme="light"]) .tooltip {
|
||||
background: var(--tooltip-bg, #FFFFFF);
|
||||
color: var(--tooltip-fg, #1A1410);
|
||||
border-color: var(--border, #e4e4e7);
|
||||
}
|
||||
|
||||
:host-context([data-theme="light"]) .tooltip::after {
|
||||
border-color: var(--tooltip-bg, #FFFFFF) transparent transparent transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot></slot>
|
||||
<div class="tooltip">${this.content}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ui-tooltip": UiTooltip;
|
||||
}
|
||||
}
|
||||
@ -75,8 +75,6 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
|
||||
|
||||
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm("Reject this device pairing request?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.pair.reject", { requestId });
|
||||
await loadDevices(state);
|
||||
@ -118,10 +116,6 @@ export async function revokeDeviceToken(
|
||||
params: { deviceId: string; role: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm(
|
||||
`Revoke token for ${params.deviceId} (${params.role})?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.token.revoke", params);
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
|
||||
@ -66,10 +66,6 @@ export async function patchSession(
|
||||
export async function deleteSession(state: SessionsState, key: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.sessionsLoading) return;
|
||||
const confirmed = window.confirm(
|
||||
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
|
||||
@ -19,6 +19,7 @@ export const icons = {
|
||||
|
||||
// UI icons
|
||||
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
|
||||
moreVertical: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>`,
|
||||
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
|
||||
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
|
||||
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
const KEY = "moltbot.control.settings.v1";
|
||||
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeMode, ColorTheme } from "./theme";
|
||||
|
||||
export type UiSettings = {
|
||||
gatewayUrl: string;
|
||||
@ -8,6 +8,7 @@ export type UiSettings = {
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
theme: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
chatFocusMode: boolean;
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
@ -27,6 +28,7 @@ export function loadSettings(): UiSettings {
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
colorTheme: "tangerine",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
@ -61,6 +63,15 @@ export function loadSettings(): UiSettings {
|
||||
parsed.theme === "system"
|
||||
? parsed.theme
|
||||
: defaults.theme,
|
||||
colorTheme:
|
||||
parsed.colorTheme === "tangerine" ||
|
||||
parsed.colorTheme === "ocean" ||
|
||||
parsed.colorTheme === "forest" ||
|
||||
parsed.colorTheme === "sunset" ||
|
||||
parsed.colorTheme === "lavender" ||
|
||||
parsed.colorTheme === "slate"
|
||||
? parsed.colorTheme
|
||||
: defaults.colorTheme,
|
||||
chatFocusMode:
|
||||
typeof parsed.chatFocusMode === "boolean"
|
||||
? parsed.chatFocusMode
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
export type ColorTheme = "tangerine" | "ocean" | "forest" | "sunset" | "lavender" | "slate";
|
||||
|
||||
const STORAGE_KEY = "clawdbot-theme";
|
||||
const COLOR_THEME_KEY = "clawdbot-color-theme";
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode === "system") return getSystemTheme();
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function getSavedTheme(): ThemeMode {
|
||||
if (typeof window === "undefined" || typeof localStorage === "undefined") {
|
||||
return "system";
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === "light" || saved === "dark" || saved === "system") {
|
||||
return saved;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function saveTheme(mode: ThemeMode): void {
|
||||
if (typeof window === "undefined" || typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
export function getSavedColorTheme(): ColorTheme {
|
||||
if (typeof window === "undefined" || typeof localStorage === "undefined") {
|
||||
return "tangerine";
|
||||
}
|
||||
const saved = localStorage.getItem(COLOR_THEME_KEY);
|
||||
if (saved === "tangerine" || saved === "ocean" || saved === "forest" ||
|
||||
saved === "sunset" || saved === "lavender" || saved === "slate") {
|
||||
return saved;
|
||||
}
|
||||
return "tangerine";
|
||||
}
|
||||
|
||||
export function saveColorTheme(colorTheme: ColorTheme): void {
|
||||
if (typeof window === "undefined" || typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(COLOR_THEME_KEY, colorTheme);
|
||||
}
|
||||
|
||||
export function applyColorTheme(colorTheme: ColorTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-color-theme", colorTheme);
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode): void {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const resolved = resolveTheme(mode);
|
||||
|
||||
if (mode === "system") {
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
root.setAttribute("data-theme", mode);
|
||||
}
|
||||
|
||||
// Update meta theme-color for mobile browsers
|
||||
updateMetaThemeColor(resolved);
|
||||
}
|
||||
|
||||
function updateMetaThemeColor(theme: ResolvedTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
let metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
|
||||
if (!metaThemeColor) {
|
||||
metaThemeColor = document.createElement("meta");
|
||||
metaThemeColor.setAttribute("name", "theme-color");
|
||||
document.head.appendChild(metaThemeColor);
|
||||
}
|
||||
|
||||
// Muted backgrounds from design system
|
||||
const color = theme === "light" ? "#FAFAF9" : "#1A1816";
|
||||
metaThemeColor.setAttribute("content", color);
|
||||
}
|
||||
|
||||
export function initTheme(): ThemeMode {
|
||||
const saved = getSavedTheme();
|
||||
const colorTheme = getSavedColorTheme();
|
||||
applyTheme(saved);
|
||||
applyColorTheme(colorTheme);
|
||||
|
||||
// Watch for system theme changes
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", () => {
|
||||
const current = getSavedTheme();
|
||||
if (current === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
export const COLOR_THEMES: Record<ColorTheme, { name: string; preview: string }> = {
|
||||
tangerine: { name: "Tangerine", preview: "#FF9F40" },
|
||||
ocean: { name: "Ocean", preview: "#0EA5E9" },
|
||||
forest: { name: "Forest", preview: "#10B981" },
|
||||
sunset: { name: "Sunset", preview: "#F43F5E" },
|
||||
lavender: { name: "Lavender", preview: "#A78BFA" },
|
||||
slate: { name: "Slate", preview: "#64748B" },
|
||||
};
|
||||
|
||||
@ -102,7 +102,10 @@ export function renderChannelConfigSection(params: {
|
||||
const { channelId, props } = params;
|
||||
const disabled = props.configSaving || props.configSchemaLoading;
|
||||
return html`
|
||||
<div style="margin-top: 16px;">
|
||||
<div class="channel-config-section">
|
||||
<div style="font-weight: 500; font-size: 13px; color: var(--muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.04em;">
|
||||
Configuration
|
||||
</div>
|
||||
${props.configSchemaLoading
|
||||
? html`<div class="muted">Loading config schema…</div>`
|
||||
: renderChannelConfigForm({
|
||||
@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: {
|
||||
disabled,
|
||||
onPatch: props.onConfigPatch,
|
||||
})}
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${disabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -11,51 +11,71 @@ export function renderDiscordCard(params: {
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, discord, accountCountLabel } = params;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Discord</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Discord</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${discord?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
||||
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "discord", props })}
|
||||
</div>
|
||||
|
||||
${discord?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${discord?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${discord?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
||||
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "discord", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -11,63 +11,83 @@ export function renderGoogleChatCard(params: {
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, googleChat, accountCountLabel } = params;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Google Chat</div>
|
||||
<div class="card-sub">Chat API webhook status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Google Chat</div>
|
||||
<div class="card-sub">Chat API webhook status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Credential</span>
|
||||
<span>${googleChat?.credentialSource ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Audience</span>
|
||||
<span>
|
||||
${googleChat?.audienceType
|
||||
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Credential</span>
|
||||
<span>${googleChat?.credentialSource ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Audience</span>
|
||||
<span>
|
||||
${googleChat?.audienceType
|
||||
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${googleChat?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
|
||||
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "googlechat", props })}
|
||||
</div>
|
||||
|
||||
${googleChat?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${googleChat.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${googleChat?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${googleChat.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${googleChat?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
|
||||
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "googlechat", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -11,51 +11,71 @@ export function renderIMessageCard(params: {
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, imessage, accountCountLabel } = params;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${imessage?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${imessage?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${imessage?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
|
||||
${imessage.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "imessage", props })}
|
||||
</div>
|
||||
|
||||
${imessage?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${imessage.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${imessage?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${imessage.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${imessage?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
|
||||
${imessage.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "imessage", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -235,36 +235,33 @@ export function renderNostrProfileForm(params: {
|
||||
: nothing}
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn primary"
|
||||
<ui-button
|
||||
variant="primary"
|
||||
@click=${callbacks.onSave}
|
||||
?disabled=${state.saving || !isDirty}
|
||||
>
|
||||
${state.saving ? "Saving..." : "Save & Publish"}
|
||||
</button>
|
||||
</ui-button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
<ui-button
|
||||
@click=${callbacks.onImport}
|
||||
?disabled=${state.importing || state.saving}
|
||||
>
|
||||
${state.importing ? "Importing..." : "Import from Relays"}
|
||||
</button>
|
||||
</ui-button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
<ui-button
|
||||
@click=${callbacks.onToggleAdvanced}
|
||||
>
|
||||
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
</button>
|
||||
</ui-button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
<ui-button
|
||||
@click=${callbacks.onCancel}
|
||||
?disabled=${state.saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
${isDirty
|
||||
|
||||
@ -50,6 +50,7 @@ export function renderNostrCard(params: {
|
||||
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
|
||||
const hasMultipleAccounts = nostrAccounts.length > 1;
|
||||
const showingForm = profileFormState !== null && profileFormState !== undefined;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const publicKey = (account as { publicKey?: string }).publicKey;
|
||||
@ -120,13 +121,12 @@ export function renderNostrCard(params: {
|
||||
<div style="font-weight: 500;">Profile</div>
|
||||
${summaryConfigured
|
||||
? html`
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
<ui-button
|
||||
size="sm"
|
||||
@click=${onEditProfile}
|
||||
style="font-size: 12px; padding: 4px 8px;"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
</ui-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
@ -167,50 +167,69 @@ export function renderNostrCard(params: {
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Nostr</div>
|
||||
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Nostr</div>
|
||||
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${nostrAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${summaryConfigured ? "Yes" : "No"}</span>
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${nostrAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${summaryRunning ? "Yes" : "No"}</span>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${summaryConfigured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${summaryRunning ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Public Key</span>
|
||||
<span class="monospace" title="${summaryPublicKey ?? ""}"
|
||||
>${truncatePubkey(summaryPublicKey)}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Public Key</span>
|
||||
<span class="monospace" title="${summaryPublicKey ?? ""}"
|
||||
>${truncatePubkey(summaryPublicKey)}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`}
|
||||
|
||||
${summaryLastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
|
||||
: nothing}
|
||||
${renderProfileSection()}
|
||||
|
||||
${renderProfileSection()}
|
||||
${renderChannelConfigSection({ channelId: "nostr", props })}
|
||||
</div>
|
||||
|
||||
${renderChannelConfigSection({ channelId: "nostr", props })}
|
||||
<div class="card-footer">
|
||||
${summaryLastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${summaryLastError}</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(false)}>Refresh</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -11,55 +11,75 @@ export function renderSignalCard(params: {
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, signal, accountCountLabel } = params;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">signal-cli status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">signal-cli status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${signal?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${signal?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Base URL</span>
|
||||
<span>${signal?.baseUrl ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${signal?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${signal?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Base URL</span>
|
||||
<span>${signal?.baseUrl ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${signal?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${signal.probe.ok ? "ok" : "failed"} ·
|
||||
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "signal", props })}
|
||||
</div>
|
||||
|
||||
${signal?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${signal.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${signal?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${signal.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${signal?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${signal.probe.ok ? "ok" : "failed"} ·
|
||||
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "signal", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -11,51 +11,71 @@ export function renderSlackCard(params: {
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, slack, accountCountLabel } = params;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Slack</div>
|
||||
<div class="card-sub">Socket mode status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Slack</div>
|
||||
<div class="card-sub">Socket mode status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${slack?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${slack.probe.ok ? "ok" : "failed"} ·
|
||||
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "slack", props })}
|
||||
</div>
|
||||
|
||||
${slack?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${slack.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${slack?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${slack.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${slack?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${slack.probe.ok ? "ok" : "failed"} ·
|
||||
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "slack", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -13,6 +13,7 @@ export function renderTelegramCard(params: {
|
||||
}) {
|
||||
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||
@ -52,61 +53,80 @@ export function renderTelegramCard(params: {
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`}
|
||||
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderChannelConfigSection({ channelId: "telegram", props })}
|
||||
</div>
|
||||
|
||||
${renderChannelConfigSection({ channelId: "telegram", props })}
|
||||
<div class="card-footer">
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</ui-button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
import { formatDuration } from "./channels.shared";
|
||||
import "../components/dropdown-menu";
|
||||
|
||||
export function renderWhatsAppCard(params: {
|
||||
props: ChannelsProps;
|
||||
@ -13,106 +14,132 @@ export function renderWhatsAppCard(params: {
|
||||
}) {
|
||||
const { props, whatsapp, accountCountLabel } = params;
|
||||
|
||||
const menuItems = [
|
||||
{ label: "Relink", value: "relink", disabled: props.whatsappBusy },
|
||||
{ label: "Wait for scan", value: "wait", disabled: props.whatsappBusy },
|
||||
{ label: "Logout", value: "logout", variant: "danger" as const, disabled: props.whatsappBusy },
|
||||
];
|
||||
|
||||
const handleMenuSelect = (e: CustomEvent) => {
|
||||
const { value } = e.detail;
|
||||
switch (value) {
|
||||
case "relink":
|
||||
props.onWhatsAppStart(true);
|
||||
break;
|
||||
case "wait":
|
||||
props.onWhatsAppWait();
|
||||
break;
|
||||
case "logout":
|
||||
props.onWhatsAppLogout();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">WhatsApp</div>
|
||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||
${accountCountLabel}
|
||||
<div class="card card--channel">
|
||||
<div class="card-content">
|
||||
<div class="card-title">WhatsApp</div>
|
||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Linked</span>
|
||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt
|
||||
? formatAgo(whatsapp.lastConnectedAt)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
<span>
|
||||
${whatsapp?.authAgeMs != null
|
||||
? formatDuration(whatsapp.authAgeMs)
|
||||
: "n/a"}
|
||||
</span>
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Linked</span>
|
||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt
|
||||
? formatAgo(whatsapp.lastConnectedAt)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
<span>
|
||||
${whatsapp?.authAgeMs != null
|
||||
? formatDuration(whatsapp.authAgeMs)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${props.whatsappMessage
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.whatsappMessage}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappQrDataUrl
|
||||
? html`<div class="qr-wrap">
|
||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "whatsapp", props })}
|
||||
</div>
|
||||
|
||||
${whatsapp?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${whatsapp.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-footer">
|
||||
${whatsapp?.lastError
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||
${whatsapp.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappMessage
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.whatsappMessage}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappQrDataUrl
|
||||
? html`<div class="qr-wrap">
|
||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(false)}
|
||||
>
|
||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(true)}
|
||||
>
|
||||
Relink
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppWait()}
|
||||
>
|
||||
Wait for scan
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppLogout()}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Refresh
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(false)}
|
||||
>
|
||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||
</ui-button>
|
||||
<ui-button @click=${() => props.onRefresh(true)}>
|
||||
Refresh
|
||||
</ui-button>
|
||||
<ui-dropdown-menu
|
||||
.items=${menuItems}
|
||||
?disabled=${props.whatsappBusy}
|
||||
@select=${handleMenuSelect}
|
||||
></ui-dropdown-menu>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${configDisabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save Config"}
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${configDisabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${renderChannelConfigSection({ channelId: "whatsapp", props })}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
} from "../chat/grouped-render";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||
import "../components/resizable-divider";
|
||||
import "../components/button";
|
||||
|
||||
export type CompactionIndicatorStatus = {
|
||||
active: boolean;
|
||||
|
||||
@ -27,13 +27,18 @@ 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>`,
|
||||
};
|
||||
import "../components";
|
||||
|
||||
// Helper to wrap generic patch with component events
|
||||
function handlePatch(
|
||||
path: Array<string | number>,
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void
|
||||
) {
|
||||
return (e: CustomEvent) => {
|
||||
const { value, checked } = e.detail;
|
||||
onPatch(path, checked ?? value);
|
||||
};
|
||||
}
|
||||
|
||||
export function renderNode(params: {
|
||||
schema: JsonSchema;
|
||||
@ -83,22 +88,21 @@ export function renderNode(params: {
|
||||
if (allLiterals && literals.length > 0 && literals.length <= 5) {
|
||||
// Use segmented control for small sets
|
||||
const resolvedValue = value ?? schema.default;
|
||||
const options = literals.map((lit) => ({ label: String(lit), value: String(lit) }));
|
||||
|
||||
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">
|
||||
${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>
|
||||
<ui-segmented-control
|
||||
.options=${options}
|
||||
.value=${String(resolvedValue)}
|
||||
@change=${(e: CustomEvent) => {
|
||||
// Find original literal type
|
||||
const selected = literals.find(l => String(l) === e.detail.value);
|
||||
onPatch(path, selected);
|
||||
}}
|
||||
></ui-segmented-control>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -142,22 +146,18 @@ export function renderNode(params: {
|
||||
const options = schema.enum;
|
||||
if (options.length <= 5) {
|
||||
const resolvedValue = value ?? schema.default;
|
||||
const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) }));
|
||||
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>
|
||||
<ui-segmented-control
|
||||
.options=${enumOptions}
|
||||
.value=${String(resolvedValue)}
|
||||
@change=${(e: CustomEvent) => {
|
||||
onPatch(path, e.detail.value);
|
||||
}}
|
||||
></ui-segmented-control>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -178,21 +178,17 @@ export function renderNode(params: {
|
||||
if (type === "boolean") {
|
||||
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
|
||||
return html`
|
||||
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
|
||||
<div 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>
|
||||
<ui-switch
|
||||
.checked=${displayValue}
|
||||
?disabled=${disabled}
|
||||
@change=${handlePatch(path, onPatch)}
|
||||
></ui-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -241,34 +237,34 @@ function renderTextInput(params: {
|
||||
${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}
|
||||
<ui-input
|
||||
.type=${isSensitive ? "password" : inputType}
|
||||
.placeholder=${placeholder}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (inputType === "number") {
|
||||
if (raw.trim() === "") {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||
return;
|
||||
}
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
/>
|
||||
@input=${(e: CustomEvent) => {
|
||||
const raw = e.detail.value;
|
||||
if (inputType === "number") {
|
||||
if (raw.trim() === "") {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||
return;
|
||||
}
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
></ui-input>
|
||||
${schema.default !== undefined ? html`
|
||||
<button
|
||||
type="button"
|
||||
<ui-button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="cfg-input__reset"
|
||||
title="Reset to default"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, schema.default)}
|
||||
>↺</button>
|
||||
>↺</ui-button>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
@ -297,29 +293,16 @@ function renderNumberInput(params: {
|
||||
${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
|
||||
<ui-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>
|
||||
@input=${(e: CustomEvent) => {
|
||||
const raw = e.detail.value;
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
onPatch(path, parsed);
|
||||
}}
|
||||
></ui-input>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -350,20 +333,18 @@ function renderSelect(params: {
|
||||
<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"
|
||||
<ui-select
|
||||
.options=${[
|
||||
{ label: "Select...", value: unset },
|
||||
...options.map((opt, idx) => ({ label: String(opt), value: String(idx) }))
|
||||
]}
|
||||
?disabled=${disabled}
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
|
||||
@change=${(e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, val === unset ? undefined : options[Number(val)]);
|
||||
}}
|
||||
>
|
||||
<option value=${unset}>Select...</option>
|
||||
${options.map((opt, idx) => html`
|
||||
<option value=${String(idx)}>${String(opt)}</option>
|
||||
`)}
|
||||
</select>
|
||||
@change=${(e: CustomEvent) => {
|
||||
const val = e.detail.value;
|
||||
onPatch(path, val === unset ? undefined : options[Number(val)]);
|
||||
}}
|
||||
></ui-select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -408,26 +389,26 @@ function renderObject(params: {
|
||||
return html`
|
||||
<div class="cfg-fields">
|
||||
${sorted.map(([propKey, node]) =>
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: obj[propKey],
|
||||
path: [...path, propKey],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
})
|
||||
)}
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: obj[propKey],
|
||||
path: [...path, propKey],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
})
|
||||
)}
|
||||
${allowExtra ? renderMapField({
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -437,31 +418,31 @@ function renderObject(params: {
|
||||
<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>
|
||||
<ui-icon name="chevronDown" class="cfg-object__chevron"></ui-icon>
|
||||
</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,
|
||||
})
|
||||
)}
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: obj[propKey],
|
||||
path: [...path, propKey],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
})
|
||||
)}
|
||||
${allowExtra ? renderMapField({
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
schema: additional as JsonSchema,
|
||||
value: obj,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
onPatch,
|
||||
}) : nothing}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
@ -499,19 +480,17 @@ function renderArray(params: {
|
||||
<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"
|
||||
<ui-badge variant="secondary">${arr.length} item${arr.length !== 1 ? 's' : ''}</ui-badge>
|
||||
<ui-button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr, defaultValue(itemsSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
<span class="cfg-array__add-icon">${icons.plus}</span>
|
||||
Add
|
||||
</button>
|
||||
const next = [...arr, defaultValue(itemsSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>Add</ui-button>
|
||||
</div>
|
||||
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
|
||||
|
||||
@ -525,31 +504,30 @@ function renderArray(params: {
|
||||
<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"
|
||||
<ui-button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="trash"
|
||||
title="Remove item"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr];
|
||||
next.splice(idx, 1);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
const next = [...arr];
|
||||
next.splice(idx, 1);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
></ui-button>
|
||||
</div>
|
||||
<div class="cfg-array__item-content">
|
||||
${renderNode({
|
||||
schema: itemsSchema,
|
||||
value: item,
|
||||
path: [...path, idx],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
schema: itemsSchema,
|
||||
value: item,
|
||||
path: [...path, idx],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
@ -577,25 +555,23 @@ function renderMapField(params: {
|
||||
<div class="cfg-map">
|
||||
<div class="cfg-map__header">
|
||||
<span class="cfg-map__label">Custom entries</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__add"
|
||||
<ui-button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
let index = 1;
|
||||
let key = `custom-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `custom-${index}`;
|
||||
}
|
||||
next[key] = anySchema ? {} : defaultValue(schema);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
<span class="cfg-map__add-icon">${icons.plus}</span>
|
||||
Add Entry
|
||||
</button>
|
||||
const next = { ...(value ?? {}) };
|
||||
let index = 1;
|
||||
let key = `custom-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `custom-${index}`;
|
||||
}
|
||||
next[key] = anySchema ? {} : defaultValue(schema);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>Add Entry</ui-button>
|
||||
</div>
|
||||
|
||||
${entries.length === 0 ? html`
|
||||
@ -603,79 +579,75 @@ function renderMapField(params: {
|
||||
` : html`
|
||||
<div class="cfg-map__items">
|
||||
${entries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
return html`
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
<ui-input
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
@change=${(e: CustomEvent) => {
|
||||
const nextKey = e.detail.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);
|
||||
}}
|
||||
></ui-input>
|
||||
</div>
|
||||
<div class="cfg-map__item-value">
|
||||
${anySchema
|
||||
? html`
|
||||
<textarea
|
||||
class="cfg-textarea cfg-textarea--sm"
|
||||
? html`
|
||||
<ui-textarea
|
||||
placeholder="JSON value"
|
||||
rows="2"
|
||||
.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>
|
||||
@change=${(e: CustomEvent) => {
|
||||
const raw = e.detail.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(valuePath, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(valuePath, JSON.parse(raw));
|
||||
} catch {
|
||||
// target.value = fallback; // ui-textarea handles its own value
|
||||
}
|
||||
}}
|
||||
></ui-textarea>
|
||||
`
|
||||
: renderNode({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: valuePath,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
: renderNode({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: valuePath,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
<ui-button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="trash"
|
||||
title="Remove entry"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
></ui-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared";
|
||||
import "../components/button";
|
||||
import "../components/badge";
|
||||
import "../components/input";
|
||||
import "../components/textarea";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@ -261,17 +265,12 @@ export function renderConfig(props: ConfigProps) {
|
||||
|
||||
<!-- 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"
|
||||
<ui-input
|
||||
placeholder="Search settings..."
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
icon="search"
|
||||
@input=${(e: CustomEvent) => props.onSearchChange(e.detail.value)}
|
||||
></ui-input>
|
||||
${props.searchQuery ? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
@ -305,7 +304,6 @@ export function renderConfig(props: ConfigProps) {
|
||||
<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
|
||||
@ -332,30 +330,31 @@ export function renderConfig(props: ConfigProps) {
|
||||
`}
|
||||
</div>
|
||||
<div class="config-actions__right">
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
<ui-button size="sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
</ui-button>
|
||||
<ui-button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
?disabled=${!canSave}
|
||||
@click=${props.onSave}
|
||||
>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
</ui-button>
|
||||
<ui-button
|
||||
size="sm"
|
||||
?disabled=${!canApply}
|
||||
@click=${props.onApply}
|
||||
>
|
||||
${props.applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
</ui-button>
|
||||
<ui-button
|
||||
size="sm"
|
||||
?disabled=${!canUpdate}
|
||||
@click=${props.onUpdate}
|
||||
>
|
||||
${props.updating ? "Updating…" : "Update"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -451,14 +450,15 @@ export function renderConfig(props: ConfigProps) {
|
||||
: nothing}
|
||||
`
|
||||
: html`
|
||||
<label class="field config-raw-field">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
<div class="config-raw-wrap">
|
||||
<ui-textarea
|
||||
label="Raw JSON5"
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
.rows=${25}
|
||||
@input=${(e: CustomEvent) =>
|
||||
props.onRawChange(e.detail.value)}
|
||||
></ui-textarea>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
|
||||
@ -64,10 +64,10 @@ export function renderCron(props: CronProps) {
|
||||
<div class="stat-label">Enabled</div>
|
||||
<div class="stat-value">
|
||||
${props.status
|
||||
? props.status.enabled
|
||||
? "Yes"
|
||||
: "No"
|
||||
: "n/a"}
|
||||
? props.status.enabled
|
||||
? "Yes"
|
||||
: "No"
|
||||
: "n/a"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@ -96,7 +96,7 @@ export function renderCron(props: CronProps) {
|
||||
<input
|
||||
.value=${props.form.name}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
@ -104,7 +104,7 @@ export function renderCron(props: CronProps) {
|
||||
<input
|
||||
.value=${props.form.description}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
@ -112,27 +112,26 @@ export function renderCron(props: CronProps) {
|
||||
<input
|
||||
.value=${props.form.agentId}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
|
||||
placeholder="default"
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<div style="display: flex; align-items: flex-end; height: 100%; padding-bottom: 10px;">
|
||||
<ui-checkbox
|
||||
label="Enabled"
|
||||
.checked=${props.form.enabled}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
@change=${(e: CustomEvent) =>
|
||||
props.onFormChange({ enabled: e.detail.checked })}
|
||||
></ui-checkbox>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Schedule</span>
|
||||
<select
|
||||
.value=${props.form.scheduleKind}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
|
||||
})}
|
||||
>
|
||||
<option value="every">Every</option>
|
||||
<option value="at">At</option>
|
||||
@ -147,9 +146,9 @@ export function renderCron(props: CronProps) {
|
||||
<select
|
||||
.value=${props.form.sessionTarget}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
|
||||
})}
|
||||
>
|
||||
<option value="main">Main</option>
|
||||
<option value="isolated">Isolated</option>
|
||||
@ -160,9 +159,9 @@ export function renderCron(props: CronProps) {
|
||||
<select
|
||||
.value=${props.form.wakeMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
|
||||
})}
|
||||
>
|
||||
<option value="next-heartbeat">Next heartbeat</option>
|
||||
<option value="now">Now</option>
|
||||
@ -173,9 +172,9 @@ export function renderCron(props: CronProps) {
|
||||
<select
|
||||
.value=${props.form.payloadKind}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
|
||||
})}
|
||||
>
|
||||
<option value="systemEvent">System event</option>
|
||||
<option value="agentTurn">Agent turn</option>
|
||||
@ -187,14 +186,14 @@ export function renderCron(props: CronProps) {
|
||||
<textarea
|
||||
.value=${props.form.payloadText}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
payloadText: (e.target as HTMLTextAreaElement).value,
|
||||
})}
|
||||
props.onFormChange({
|
||||
payloadText: (e.target as HTMLTextAreaElement).value,
|
||||
})}
|
||||
rows="4"
|
||||
></textarea>
|
||||
</label>
|
||||
${props.form.payloadKind === "agentTurn"
|
||||
? html`
|
||||
? html`
|
||||
<div class="form-grid" style="margin-top: 12px;">
|
||||
<label class="field checkbox">
|
||||
<span>Deliver</span>
|
||||
@ -202,26 +201,26 @@ export function renderCron(props: CronProps) {
|
||||
type="checkbox"
|
||||
.checked=${props.form.deliver}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
deliver: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
props.onFormChange({
|
||||
deliver: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Channel</span>
|
||||
<select
|
||||
.value=${props.form.channel || "last"}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
|
||||
})}
|
||||
>
|
||||
${channelOptions.map(
|
||||
(channel) =>
|
||||
html`<option value=${channel}>
|
||||
(channel) =>
|
||||
html`<option value=${channel}>
|
||||
${resolveChannelLabel(props, channel)}
|
||||
</option>`,
|
||||
)}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
@ -229,7 +228,7 @@ export function renderCron(props: CronProps) {
|
||||
<input
|
||||
.value=${props.form.to}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
||||
placeholder="+1555… or chat id"
|
||||
/>
|
||||
</label>
|
||||
@ -238,28 +237,28 @@ export function renderCron(props: CronProps) {
|
||||
<input
|
||||
.value=${props.form.timeoutSeconds}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
timeoutSeconds: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
props.onFormChange({
|
||||
timeoutSeconds: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
${props.form.sessionTarget === "isolated"
|
||||
? html`
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>Post to main prefix</span>
|
||||
<input
|
||||
.value=${props.form.postToMainPrefix}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
postToMainPrefix: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
props.onFormChange({
|
||||
postToMainPrefix: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
||||
${props.busy ? "Saving…" : "Add job"}
|
||||
@ -272,8 +271,8 @@ export function renderCron(props: CronProps) {
|
||||
<div class="card-title">Jobs</div>
|
||||
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
|
||||
${props.jobs.length === 0
|
||||
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
|
||||
: html`
|
||||
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
|
||||
: html`
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
${props.jobs.map((job) => renderJob(job, props))}
|
||||
</div>
|
||||
@ -284,14 +283,14 @@ export function renderCron(props: CronProps) {
|
||||
<div class="card-title">Run history</div>
|
||||
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
|
||||
${props.runsJobId == null
|
||||
? html`
|
||||
? html`
|
||||
<div class="muted" style="margin-top: 12px;">
|
||||
Select a job to inspect run history.
|
||||
</div>
|
||||
`
|
||||
: props.runs.length === 0
|
||||
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
|
||||
: html`
|
||||
: props.runs.length === 0
|
||||
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
|
||||
: html`
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
${props.runs.map((entry) => renderRun(entry))}
|
||||
</div>
|
||||
@ -310,9 +309,9 @@ function renderScheduleFields(props: CronProps) {
|
||||
type="datetime-local"
|
||||
.value=${form.scheduleAt}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
scheduleAt: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
props.onFormChange({
|
||||
scheduleAt: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
@ -325,9 +324,9 @@ function renderScheduleFields(props: CronProps) {
|
||||
<input
|
||||
.value=${form.everyAmount}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
everyAmount: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
props.onFormChange({
|
||||
everyAmount: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
@ -335,9 +334,9 @@ function renderScheduleFields(props: CronProps) {
|
||||
<select
|
||||
.value=${form.everyUnit}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
|
||||
})}
|
||||
props.onFormChange({
|
||||
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
|
||||
})}
|
||||
>
|
||||
<option value="minutes">Minutes</option>
|
||||
<option value="hours">Hours</option>
|
||||
@ -354,7 +353,7 @@ function renderScheduleFields(props: CronProps) {
|
||||
<input
|
||||
.value=${form.cronExpr}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
@ -362,7 +361,7 @@ function renderScheduleFields(props: CronProps) {
|
||||
<input
|
||||
.value=${form.cronTz}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
|
||||
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@ -392,9 +391,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation();
|
||||
props.onToggle(job, !job.enabled);
|
||||
}}
|
||||
event.stopPropagation();
|
||||
props.onToggle(job, !job.enabled);
|
||||
}}
|
||||
>
|
||||
${job.enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
@ -402,9 +401,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation();
|
||||
props.onRun(job);
|
||||
}}
|
||||
event.stopPropagation();
|
||||
props.onRun(job);
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
@ -412,9 +411,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation();
|
||||
props.onLoadRuns(job.id);
|
||||
}}
|
||||
event.stopPropagation();
|
||||
props.onLoadRuns(job.id);
|
||||
}}
|
||||
>
|
||||
Runs
|
||||
</button>
|
||||
@ -422,9 +421,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
class="btn danger"
|
||||
?disabled=${props.busy}
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation();
|
||||
props.onRemove(job);
|
||||
}}
|
||||
event.stopPropagation();
|
||||
props.onRemove(job);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@ -2,6 +2,8 @@ import { html, nothing } from "lit";
|
||||
|
||||
import { formatEventPayload } from "../presenter";
|
||||
import type { EventLogEntry } from "../app-events";
|
||||
import "../components/button";
|
||||
import "../components/input";
|
||||
|
||||
export type DebugProps = {
|
||||
loading: boolean;
|
||||
@ -45,9 +47,9 @@ export function renderDebug(props: DebugProps) {
|
||||
<div class="card-title">Snapshots</div>
|
||||
<div class="card-sub">Status, health, and heartbeat data.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
<div class="stack" style="margin-top: 12px;">
|
||||
<div>
|
||||
@ -74,28 +76,26 @@ export function renderDebug(props: DebugProps) {
|
||||
<div class="card">
|
||||
<div class="card-title">Manual RPC</div>
|
||||
<div class="card-sub">Send a raw gateway method with JSON params.</div>
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Method</span>
|
||||
<input
|
||||
.value=${props.callMethod}
|
||||
@input=${(e: Event) =>
|
||||
props.onCallMethodChange((e.target as HTMLInputElement).value)}
|
||||
placeholder="system-presence"
|
||||
/>
|
||||
</label>
|
||||
<div style="margin-top: 16px; display: grid; gap: 16px;">
|
||||
<ui-input
|
||||
label="Method"
|
||||
.value=${props.callMethod}
|
||||
placeholder="system-presence"
|
||||
@input=${(e: CustomEvent) => props.onCallMethodChange(e.detail.value)}
|
||||
></ui-input>
|
||||
<label class="field">
|
||||
<span>Params (JSON)</span>
|
||||
<textarea
|
||||
.value=${props.callParams}
|
||||
@input=${(e: Event) =>
|
||||
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="{}"
|
||||
rows="6"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn primary" @click=${props.onCall}>Call</button>
|
||||
<div style="margin-top: 16px;">
|
||||
<ui-button variant="primary" @click=${props.onCall}>Call</ui-button>
|
||||
</div>
|
||||
${props.callError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { LogEntry, LogLevel } from "../types";
|
||||
import "../components/button";
|
||||
import "../components/input";
|
||||
import "../components/checkbox";
|
||||
|
||||
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
|
||||
@ -54,73 +57,70 @@ export function renderLogs(props: LogsProps) {
|
||||
<div class="card-sub">Gateway file logs (JSONL).</div>
|
||||
</div>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
</ui-button>
|
||||
<ui-button
|
||||
?disabled=${filtered.length === 0}
|
||||
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
|
||||
>
|
||||
Export ${exportLabel}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field" style="min-width: 220px;">
|
||||
<span>Filter</span>
|
||||
<input
|
||||
.value=${props.filterText}
|
||||
@input=${(e: Event) =>
|
||||
props.onFilterTextChange((e.target as HTMLInputElement).value)}
|
||||
placeholder="Search logs"
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Auto-follow</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-input
|
||||
label="Filter"
|
||||
.value=${props.filterText}
|
||||
@input=${(e: CustomEvent) => props.onFilterTextChange(e.detail.value)}
|
||||
placeholder="Search logs"
|
||||
style="width: 240px;"
|
||||
></ui-input>
|
||||
<div style="margin-top: 19px;">
|
||||
<ui-checkbox
|
||||
label="Auto-follow"
|
||||
.checked=${props.autoFollow}
|
||||
@change=${(e: Event) =>
|
||||
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
@change=${(e: CustomEvent) => props.onToggleAutoFollow(e.detail.checked)}
|
||||
></ui-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chip-row" style="margin-top: 12px;">
|
||||
<div style="margin-top: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;">
|
||||
${LEVELS.map(
|
||||
(level) => html`
|
||||
<label class="chip log-chip ${level}">
|
||||
(level) => html`
|
||||
<label class="log-level-filter log-level-filter--${level}" style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.levelFilters[level]}
|
||||
@change=${(e: Event) =>
|
||||
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
|
||||
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
|
||||
style="display: none;"
|
||||
/>
|
||||
<span>${level}</span>
|
||||
<span class="log-level-filter__checkbox"></span>
|
||||
<span class="log-level-filter__label">${level}</span>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
${props.file
|
||||
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
|
||||
: nothing}
|
||||
? html`<div class="muted" style="margin-top: 12px;">File: ${props.file}</div>`
|
||||
: nothing}
|
||||
${props.truncated
|
||||
? html`<div class="callout" style="margin-top: 10px;">
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Log output truncated; showing latest chunk.
|
||||
</div>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${props.error
|
||||
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
|
||||
: nothing}
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
|
||||
<div class="log-stream" style="margin-top: 16px;" @scroll=${props.onScroll}>
|
||||
${filtered.length === 0
|
||||
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
|
||||
: filtered.map(
|
||||
(entry) => html`
|
||||
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
|
||||
: filtered.map(
|
||||
(entry) => html`
|
||||
<div class="log-row">
|
||||
<div class="log-time mono">${formatTime(entry.time)}</div>
|
||||
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div>
|
||||
@ -128,7 +128,7 @@ export function renderLogs(props: LogsProps) {
|
||||
<div class="log-message mono">${entry.message ?? entry.raw}</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@ -12,6 +12,12 @@ import type {
|
||||
PairedDevice,
|
||||
PendingDevice,
|
||||
} from "../controllers/devices";
|
||||
import "../components/button";
|
||||
import "../components/badge";
|
||||
import "../components/input";
|
||||
import "../components/card";
|
||||
import "../components/checkbox";
|
||||
import "../components/select";
|
||||
|
||||
export type NodesProps = {
|
||||
loading: boolean;
|
||||
@ -57,22 +63,24 @@ export function renderNodes(props: NodesProps) {
|
||||
${renderExecApprovals(approvalsState)}
|
||||
${renderBindings(bindingState)}
|
||||
${renderDevices(props)}
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Nodes</div>
|
||||
<div class="card-sub">Paired devices and live links.</div>
|
||||
<ui-card>
|
||||
<div slot="header">
|
||||
<div class="row" style="justify-content: space-between; width: 100%;">
|
||||
<div>
|
||||
<div class="card-title">Nodes</div>
|
||||
<div class="card-desc">Paired devices and live links.</div>
|
||||
</div>
|
||||
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</ui-button>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
<div class="list">
|
||||
${props.nodes.length === 0
|
||||
? html`<div class="muted">No nodes found.</div>`
|
||||
: props.nodes.map((n) => renderNode(n))}
|
||||
? html`<div class="muted">No nodes found.</div>`
|
||||
: props.nodes.map((n) => renderNode(n))}
|
||||
</div>
|
||||
</section>
|
||||
</ui-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -81,37 +89,39 @@ function renderDevices(props: NodesProps) {
|
||||
const pending = Array.isArray(list.pending) ? list.pending : [];
|
||||
const paired = Array.isArray(list.paired) ? list.paired : [];
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Devices</div>
|
||||
<div class="card-sub">Pairing requests + role tokens.</div>
|
||||
<ui-card>
|
||||
<div slot="header">
|
||||
<div class="row" style="justify-content: space-between; width: 100%;">
|
||||
<div>
|
||||
<div class="card-title">Devices</div>
|
||||
<div class="card-desc">Pairing requests + role tokens.</div>
|
||||
</div>
|
||||
<ui-button ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
||||
${props.devicesLoading ? "Loading…" : "Refresh"}
|
||||
</ui-button>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
||||
${props.devicesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${props.devicesError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
|
||||
: nothing}
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.devicesError}</div>`
|
||||
: nothing}
|
||||
<div class="list">
|
||||
${pending.length > 0
|
||||
? html`
|
||||
? html`
|
||||
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
||||
${pending.map((req) => renderPendingDevice(req, props))}
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${paired.length > 0
|
||||
? html`
|
||||
? html`
|
||||
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
||||
${paired.map((device) => renderPairedDevice(device, props))}
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${pending.length === 0 && paired.length === 0
|
||||
? html`<div class="muted">No paired devices.</div>`
|
||||
: nothing}
|
||||
? html`<div class="muted">No paired devices.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
</ui-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -132,12 +142,12 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
|
||||
<ui-button variant="primary" size="sm" @click=${() => props.onDeviceApprove(req.requestId)}>
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||
</ui-button>
|
||||
<ui-button size="sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||
Reject
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -157,8 +167,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
||||
<div class="list-sub">${device.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
||||
${tokens.length === 0
|
||||
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||
: html`
|
||||
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||
: html`
|
||||
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
||||
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
|
||||
@ -177,21 +187,22 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
|
||||
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
<ui-button
|
||||
size="sm"
|
||||
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
</ui-button>
|
||||
${token.revokedAtMs
|
||||
? nothing
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
? nothing
|
||||
: html`
|
||||
<ui-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</ui-button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
@ -399,11 +410,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
|
||||
const selectedAgent =
|
||||
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
|
||||
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
|
||||
null
|
||||
null
|
||||
: null;
|
||||
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
|
||||
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
|
||||
[])
|
||||
[])
|
||||
: [];
|
||||
return {
|
||||
ready,
|
||||
@ -433,80 +444,71 @@ function renderBindings(state: BindingState) {
|
||||
const supportsBinding = state.nodes.length > 0;
|
||||
const defaultValue = state.defaultBinding ?? "";
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between; align-items: center;">
|
||||
<ui-card>
|
||||
<div slot="header" style="display: contents;">
|
||||
<div>
|
||||
<div class="card-title">Exec node binding</div>
|
||||
<div class="card-sub">
|
||||
<div class="card-desc">
|
||||
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${state.disabled || !state.configDirty}
|
||||
@click=${state.onSave}
|
||||
>
|
||||
${state.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
${state.formMode === "raw"
|
||||
? html`<div class="callout warn" style="margin-top: 12px;">
|
||||
? html`<div class="callout warn" style="margin-bottom: 12px;">
|
||||
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
|
||||
</div>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
|
||||
${!state.ready
|
||||
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||
<div class="muted">Load config to edit bindings.</div>
|
||||
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
|
||||
<ui-button ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
|
||||
${state.configLoading ? "Loading…" : "Load config"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
: html`
|
||||
<div class="list">
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">Default binding</div>
|
||||
<div class="list-sub">Used when agents do not override a node binding.</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<label class="field">
|
||||
<span>Node</span>
|
||||
<select
|
||||
?disabled=${state.disabled || !supportsBinding}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value.trim();
|
||||
state.onBindDefault(value ? value : null);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${defaultValue === ""}>Any node</option>
|
||||
${state.nodes.map(
|
||||
(node) =>
|
||||
html`<option
|
||||
value=${node.id}
|
||||
?selected=${defaultValue === node.id}
|
||||
>
|
||||
${node.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<ui-select
|
||||
label="Node"
|
||||
?disabled=${state.disabled || !supportsBinding}
|
||||
.value=${defaultValue}
|
||||
.options=${[
|
||||
{ label: "Any node", value: "" },
|
||||
...state.nodes.map(n => ({ label: n.label, value: n.id }))
|
||||
]}
|
||||
@change=${(event: CustomEvent) => {
|
||||
const value = event.detail.value.trim();
|
||||
state.onBindDefault(value ? value : null);
|
||||
}}
|
||||
></ui-select>
|
||||
${!supportsBinding
|
||||
? html`<div class="muted">No nodes with system.run available.</div>`
|
||||
: nothing}
|
||||
? html`<div class="muted" style="margin-top: 4px; font-size: 11px;">No nodes with system.run available.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${state.agents.length === 0
|
||||
? html`<div class="muted">No agents found.</div>`
|
||||
: state.agents.map((agent) =>
|
||||
renderAgentBinding(agent, state),
|
||||
)}
|
||||
? html`<div class="muted">No agents found.</div>`
|
||||
: state.agents.map((agent) =>
|
||||
renderAgentBinding(agent, state),
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
</ui-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -514,40 +516,40 @@ function renderExecApprovals(state: ExecApprovalsState) {
|
||||
const ready = state.ready;
|
||||
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between; align-items: center;">
|
||||
<ui-card>
|
||||
<div slot="header" style="display: contents;">
|
||||
<div>
|
||||
<div class="card-title">Exec approvals</div>
|
||||
<div class="card-sub">
|
||||
<div class="card-desc">
|
||||
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
<ui-button
|
||||
variant="primary"
|
||||
?disabled=${state.disabled || !state.dirty || !targetReady}
|
||||
@click=${state.onSave}
|
||||
>
|
||||
${state.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
${renderExecApprovalsTarget(state)}
|
||||
|
||||
${!ready
|
||||
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||
<div class="muted">Load exec approvals to edit allowlists.</div>
|
||||
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
|
||||
<ui-button ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
|
||||
${state.loading ? "Loading…" : "Load approvals"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>`
|
||||
: html`
|
||||
: html`
|
||||
${renderExecApprovalsTabs(state)}
|
||||
${renderExecApprovalsPolicy(state)}
|
||||
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
|
||||
? nothing
|
||||
: renderExecApprovalsAllowlist(state)}
|
||||
? nothing
|
||||
: renderExecApprovalsAllowlist(state)}
|
||||
`}
|
||||
</section>
|
||||
</ui-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -555,7 +557,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
|
||||
const hasNodes = state.targetNodes.length > 0;
|
||||
const nodeValue = state.targetNodeId ?? "";
|
||||
return html`
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
<div class="list" style="margin-top: 12px; margin-bottom: 12px;">
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">Target</div>
|
||||
@ -564,56 +566,46 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<label class="field">
|
||||
<span>Host</span>
|
||||
<select
|
||||
?disabled=${state.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (value === "node") {
|
||||
const first = state.targetNodes[0]?.id ?? null;
|
||||
state.onSelectTarget("node", nodeValue || first);
|
||||
} else {
|
||||
state.onSelectTarget("gateway", null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
|
||||
<option value="node" ?selected=${state.target === "node"}>Node</option>
|
||||
</select>
|
||||
</label>
|
||||
<ui-select
|
||||
label="Host"
|
||||
?disabled=${state.disabled}
|
||||
.value=${state.target}
|
||||
.options=${[
|
||||
{ label: "Gateway", value: "gateway" },
|
||||
{ label: "Node", value: "node" }
|
||||
]}
|
||||
@change=${(event: CustomEvent) => {
|
||||
const value = event.detail.value;
|
||||
if (value === "node") {
|
||||
const first = state.targetNodes[0]?.id ?? null;
|
||||
state.onSelectTarget("node", nodeValue || first);
|
||||
} else {
|
||||
state.onSelectTarget("gateway", null);
|
||||
}
|
||||
}}
|
||||
></ui-select>
|
||||
${state.target === "node"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>Node</span>
|
||||
<select
|
||||
?disabled=${state.disabled || !hasNodes}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value.trim();
|
||||
state.onSelectTarget("node", value ? value : null);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${nodeValue === ""}>Select node</option>
|
||||
${state.targetNodes.map(
|
||||
(node) =>
|
||||
html`<option
|
||||
value=${node.id}
|
||||
?selected=${nodeValue === node.id}
|
||||
>
|
||||
${node.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
? html`
|
||||
<ui-select
|
||||
label="Node"
|
||||
?disabled=${state.disabled || !hasNodes}
|
||||
.value=${nodeValue}
|
||||
.options=${[
|
||||
{ label: "Select node", value: "" },
|
||||
...state.targetNodes.map(n => ({ label: n.label, value: n.id }))
|
||||
]}
|
||||
@change=${(event: CustomEvent) => {
|
||||
const value = event.detail.value.trim();
|
||||
state.onSelectTarget("node", value ? value : null);
|
||||
}}
|
||||
></ui-select>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
${state.target === "node" && !hasNodes
|
||||
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
|
||||
: nothing}
|
||||
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -623,23 +615,25 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
|
||||
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
|
||||
<span class="label">Scope</span>
|
||||
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
|
||||
<ui-button
|
||||
size="sm"
|
||||
variant=${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "primary" : "secondary"}
|
||||
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
|
||||
>
|
||||
Defaults
|
||||
</button>
|
||||
</ui-button>
|
||||
${state.agents.map((agent) => {
|
||||
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
|
||||
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
||||
return html`
|
||||
<ui-button
|
||||
size="sm"
|
||||
variant=${state.selectedScope === agent.id ? "primary" : "secondary"}
|
||||
@click=${() => state.onSelectScope(agent.id)}
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
</ui-button>
|
||||
`;
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -671,8 +665,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<div class="list-title">Security</div>
|
||||
<div class="list-sub">
|
||||
${isDefaults
|
||||
? "Default security mode."
|
||||
: `Default: ${defaults.security}.`}
|
||||
? "Default security mode."
|
||||
: `Default: ${defaults.security}.`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
@ -681,29 +675,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<select
|
||||
?disabled=${state.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "security"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "security"], value);
|
||||
}
|
||||
}}
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "security"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "security"], value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${!isDefaults
|
||||
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
|
||||
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
|
||||
Use default (${defaults.security})
|
||||
</option>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${SECURITY_OPTIONS.map(
|
||||
(option) =>
|
||||
html`<option
|
||||
(option) =>
|
||||
html`<option
|
||||
value=${option.value}
|
||||
?selected=${securityValue === option.value}
|
||||
>
|
||||
${option.label}
|
||||
</option>`,
|
||||
)}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -722,29 +716,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<select
|
||||
?disabled=${state.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "ask"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "ask"], value);
|
||||
}
|
||||
}}
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "ask"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "ask"], value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${!isDefaults
|
||||
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
|
||||
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
|
||||
Use default (${defaults.ask})
|
||||
</option>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${ASK_OPTIONS.map(
|
||||
(option) =>
|
||||
html`<option
|
||||
(option) =>
|
||||
html`<option
|
||||
value=${option.value}
|
||||
?selected=${askValue === option.value}
|
||||
>
|
||||
${option.label}
|
||||
</option>`,
|
||||
)}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -755,8 +749,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<div class="list-title">Ask fallback</div>
|
||||
<div class="list-sub">
|
||||
${isDefaults
|
||||
? "Applied when the UI prompt is unavailable."
|
||||
: `Default: ${defaults.askFallback}.`}
|
||||
? "Applied when the UI prompt is unavailable."
|
||||
: `Default: ${defaults.askFallback}.`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
@ -765,29 +759,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<select
|
||||
?disabled=${state.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "askFallback"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "askFallback"], value);
|
||||
}
|
||||
}}
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
if (!isDefaults && value === "__default__") {
|
||||
state.onRemove([...basePath, "askFallback"]);
|
||||
} else {
|
||||
state.onPatch([...basePath, "askFallback"], value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${!isDefaults
|
||||
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
|
||||
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
|
||||
Use default (${defaults.askFallback})
|
||||
</option>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${SECURITY_OPTIONS.map(
|
||||
(option) =>
|
||||
html`<option
|
||||
(option) =>
|
||||
html`<option
|
||||
value=${option.value}
|
||||
?selected=${askFallbackValue === option.value}
|
||||
>
|
||||
${option.label}
|
||||
</option>`,
|
||||
)}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -798,10 +792,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
<div class="list-title">Auto-allow skill CLIs</div>
|
||||
<div class="list-sub">
|
||||
${isDefaults
|
||||
? "Allow skill executables listed by the Gateway."
|
||||
: autoIsDefault
|
||||
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
|
||||
: `Override (${autoEffective ? "on" : "off"}).`}
|
||||
? "Allow skill executables listed by the Gateway."
|
||||
: autoIsDefault
|
||||
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
|
||||
: `Override (${autoEffective ? "on" : "off"}).`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
@ -812,20 +806,20 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
||||
?disabled=${state.disabled}
|
||||
.checked=${autoEffective}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
|
||||
}}
|
||||
const target = event.target as HTMLInputElement;
|
||||
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
${!isDefaults && !autoIsDefault
|
||||
? html`<button
|
||||
? html`<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${state.disabled}
|
||||
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
|
||||
>
|
||||
Use default
|
||||
</button>`
|
||||
: nothing}
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -845,19 +839,19 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
|
||||
class="btn btn--sm"
|
||||
?disabled=${state.disabled}
|
||||
@click=${() => {
|
||||
const next = [...entries, { pattern: "" }];
|
||||
state.onPatch(allowlistPath, next);
|
||||
}}
|
||||
const next = [...entries, { pattern: "" }];
|
||||
state.onPatch(allowlistPath, next);
|
||||
}}
|
||||
>
|
||||
Add pattern
|
||||
</button>
|
||||
</div>
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
${entries.length === 0
|
||||
? html`<div class="muted">No allowlist entries yet.</div>`
|
||||
: entries.map((entry, index) =>
|
||||
renderAllowlistEntry(state, entry, index),
|
||||
)}
|
||||
? html`<div class="muted">No allowlist entries yet.</div>`
|
||||
: entries.map((entry, index) =>
|
||||
renderAllowlistEntry(state, entry, index),
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -890,24 +884,24 @@ function renderAllowlistEntry(
|
||||
.value=${entry.pattern ?? ""}
|
||||
?disabled=${state.disabled}
|
||||
@input=${(event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
state.onPatch(
|
||||
["agents", state.selectedScope, "allowlist", index, "pattern"],
|
||||
target.value,
|
||||
);
|
||||
}}
|
||||
const target = event.target as HTMLInputElement;
|
||||
state.onPatch(
|
||||
["agents", state.selectedScope, "allowlist", index, "pattern"],
|
||||
target.value,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
?disabled=${state.disabled}
|
||||
@click=${() => {
|
||||
if (state.allowlist.length <= 1) {
|
||||
state.onRemove(["agents", state.selectedScope, "allowlist"]);
|
||||
return;
|
||||
}
|
||||
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
|
||||
}}
|
||||
if (state.allowlist.length <= 1) {
|
||||
state.onRemove(["agents", state.selectedScope, "allowlist"]);
|
||||
return;
|
||||
}
|
||||
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@ -927,8 +921,8 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
||||
<div class="list-sub">
|
||||
${agent.isDefault ? "default agent" : "agent"} ·
|
||||
${bindingValue === "__default__"
|
||||
? `uses default (${state.defaultBinding ?? "any"})`
|
||||
: `override: ${agent.binding}`}
|
||||
? `uses default (${state.defaultBinding ?? "any"})`
|
||||
: `override: ${agent.binding}`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
@ -937,23 +931,23 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
||||
<select
|
||||
?disabled=${state.disabled || !supportsBinding}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value.trim();
|
||||
state.onBindAgent(agent.index, value === "__default__" ? null : value);
|
||||
}}
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value.trim();
|
||||
state.onBindAgent(agent.index, value === "__default__" ? null : value);
|
||||
}}
|
||||
>
|
||||
<option value="__default__" ?selected=${bindingValue === "__default__"}>
|
||||
Use default
|
||||
</option>
|
||||
${state.nodes.map(
|
||||
(node) =>
|
||||
html`<option
|
||||
(node) =>
|
||||
html`<option
|
||||
value=${node.id}
|
||||
?selected=${bindingValue === node.id}
|
||||
>
|
||||
${node.label}
|
||||
</option>`,
|
||||
)}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -1078,8 +1072,8 @@ function renderNode(node: Record<string, unknown>) {
|
||||
</span>
|
||||
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
${commands
|
||||
.slice(0, 8)
|
||||
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
.slice(0, 8)
|
||||
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,10 @@ import type { GatewayHelloOk } from "../gateway";
|
||||
import { formatAgo, formatDurationMs } from "../format";
|
||||
import { formatNextRun } from "../presenter";
|
||||
import type { UiSettings } from "../storage";
|
||||
import "../components/button";
|
||||
import "../components/input";
|
||||
import "../components/card";
|
||||
import "../components/badge";
|
||||
|
||||
export type OverviewProps = {
|
||||
connected: boolean;
|
||||
@ -118,70 +122,64 @@ export function renderOverview(props: OverviewProps) {
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
<div class="card">
|
||||
<div class="card-title">Gateway Access</div>
|
||||
<div class="card-sub">Where the dashboard connects and how it authenticates.</div>
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>WebSocket URL</span>
|
||||
<input
|
||||
.value=${props.settings.gatewayUrl}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
|
||||
}}
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Gateway Token</span>
|
||||
<input
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, token: v });
|
||||
}}
|
||||
placeholder="CLAWDBOT_GATEWAY_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password (not stored)</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onPasswordChange(v);
|
||||
}}
|
||||
placeholder="system or shared password"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Default Session Key</span>
|
||||
<input
|
||||
.value=${props.settings.sessionKey}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSessionKeyChange(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<ui-card>
|
||||
<div slot="header">
|
||||
<div class="card-title">Gateway Access</div>
|
||||
<div class="card-desc">Where the dashboard connects and how it authenticates.</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<ui-input
|
||||
label="WebSocket URL"
|
||||
.value=${props.settings.gatewayUrl}
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
@input=${(e: CustomEvent) => {
|
||||
props.onSettingsChange({ ...props.settings, gatewayUrl: e.detail.value });
|
||||
}}
|
||||
></ui-input>
|
||||
<ui-input
|
||||
label="Gateway Token"
|
||||
.value=${props.settings.token}
|
||||
placeholder="CLAWDBOT_GATEWAY_TOKEN"
|
||||
@input=${(e: CustomEvent) => {
|
||||
props.onSettingsChange({ ...props.settings, token: e.detail.value });
|
||||
}}
|
||||
></ui-input>
|
||||
<ui-input
|
||||
label="Password (not stored)"
|
||||
type="password"
|
||||
.value=${props.password}
|
||||
placeholder="system or shared password"
|
||||
@input=${(e: CustomEvent) => {
|
||||
props.onPasswordChange(e.detail.value);
|
||||
}}
|
||||
></ui-input>
|
||||
<ui-input
|
||||
label="Default Session Key"
|
||||
.value=${props.settings.sessionKey}
|
||||
@input=${(e: CustomEvent) => {
|
||||
props.onSessionKeyChange(e.detail.value);
|
||||
}}
|
||||
></ui-input>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
|
||||
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
|
||||
<ui-button variant="primary" @click=${() => props.onConnect()}>Connect</ui-button>
|
||||
<ui-button variant="secondary" @click=${() => props.onRefresh()}>Refresh</ui-button>
|
||||
<span class="muted">Click Connect to apply connection changes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</ui-card>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Snapshot</div>
|
||||
<div class="card-sub">Latest gateway handshake information.</div>
|
||||
<div class="stat-grid" style="margin-top: 16px;">
|
||||
<ui-card>
|
||||
<div slot="header">
|
||||
<div class="card-title">Snapshot</div>
|
||||
<div class="card-desc">Latest gateway handshake information.</div>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Status</div>
|
||||
<div class="stat-value ${props.connected ? "ok" : "warn"}">
|
||||
${props.connected ? "Connected" : "Disconnected"}
|
||||
<div class="stat-value">
|
||||
<ui-badge variant=${props.connected ? "success" : "secondary"}>
|
||||
${props.connected ? "Connected" : "Disconnected"}
|
||||
</ui-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@ -210,21 +208,21 @@ export function renderOverview(props: OverviewProps) {
|
||||
: html`<div class="callout" style="margin-top: 14px;">
|
||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
</div>`}
|
||||
</div>
|
||||
</ui-card>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-3" style="margin-top: 18px;">
|
||||
<div class="card stat-card">
|
||||
<ui-card class="stat-card">
|
||||
<div class="stat-label">Instances</div>
|
||||
<div class="stat-value">${props.presenceCount}</div>
|
||||
<div class="muted">Presence beacons in the last 5 minutes.</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
</ui-card>
|
||||
<ui-card class="stat-card">
|
||||
<div class="stat-label">Sessions</div>
|
||||
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
|
||||
<div class="muted">Recent session keys tracked by the gateway.</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
</ui-card>
|
||||
<ui-card class="stat-card">
|
||||
<div class="stat-label">Cron</div>
|
||||
<div class="stat-value">
|
||||
${props.cronEnabled == null
|
||||
@ -234,13 +232,15 @@ export function renderOverview(props: OverviewProps) {
|
||||
: "Disabled"}
|
||||
</div>
|
||||
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
|
||||
</div>
|
||||
</ui-card>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 18px;">
|
||||
<div class="card-title">Notes</div>
|
||||
<div class="card-sub">Quick reminders for remote control setups.</div>
|
||||
<div class="note-grid" style="margin-top: 14px;">
|
||||
<ui-card style="margin-top: 18px;">
|
||||
<div slot="header">
|
||||
<div class="card-title">Notes</div>
|
||||
<div class="card-desc">Quick reminders for remote control setups.</div>
|
||||
</div>
|
||||
<div class="note-grid">
|
||||
<div>
|
||||
<div class="note-title">Tailscale serve</div>
|
||||
<div class="muted">
|
||||
@ -256,6 +256,6 @@ export function renderOverview(props: OverviewProps) {
|
||||
<div class="muted">Use isolated sessions for recurring runs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ui-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@ import { formatAgo } from "../format";
|
||||
import { formatSessionTokens } from "../presenter";
|
||||
import { pathForTab } from "../navigation";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../types";
|
||||
import "../components/button";
|
||||
import "../components/input";
|
||||
import "../components/checkbox";
|
||||
|
||||
export type SessionsProps = {
|
||||
loading: boolean;
|
||||
@ -79,71 +82,65 @@ export function renderSessions(props: SessionsProps) {
|
||||
<div class="card-title">Sessions</div>
|
||||
<div class="card-sub">Active session keys and per-session overrides.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field">
|
||||
<span>Active within (minutes)</span>
|
||||
<input
|
||||
.value=${props.activeMinutes}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: (e.target as HTMLInputElement).value,
|
||||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Limit</span>
|
||||
<input
|
||||
.value=${props.limit}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: (e.target as HTMLInputElement).value,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include global</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
|
||||
<ui-input
|
||||
label="Active within (minutes)"
|
||||
.value=${props.activeMinutes}
|
||||
@input=${(e: CustomEvent) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: e.detail.value,
|
||||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
style="width: 200px;"
|
||||
></ui-input>
|
||||
<ui-input
|
||||
label="Limit"
|
||||
.value=${props.limit}
|
||||
@input=${(e: CustomEvent) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: e.detail.value,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
style="width: 120px;"
|
||||
></ui-input>
|
||||
<div style="display: flex; gap: 16px; align-items: center; margin-top: 19px;">
|
||||
<ui-checkbox
|
||||
label="Include global"
|
||||
.checked=${props.includeGlobal}
|
||||
@change=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: props.limit,
|
||||
includeGlobal: (e.target as HTMLInputElement).checked,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include unknown</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
@change=${(e: CustomEvent) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: props.limit,
|
||||
includeGlobal: e.detail.checked,
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
></ui-checkbox>
|
||||
<ui-checkbox
|
||||
label="Include unknown"
|
||||
.checked=${props.includeUnknown}
|
||||
@change=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
@change=${(e: CustomEvent) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: e.detail.checked,
|
||||
})}
|
||||
></ui-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing}
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="muted" style="margin-top: 12px;">
|
||||
${props.result ? `Store: ${props.result.path}` : ""}
|
||||
@ -162,10 +159,10 @@ export function renderSessions(props: SessionsProps) {
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
${rows.length === 0
|
||||
? html`<div class="muted">No sessions found.</div>`
|
||||
: rows.map((row) =>
|
||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||
)}
|
||||
? html`<div class="muted">No sessions found.</div>`
|
||||
: rows.map((row) =>
|
||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
@ -194,17 +191,17 @@ function renderRow(
|
||||
return html`
|
||||
<div class="table-row">
|
||||
<div class="mono">${canLink
|
||||
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
|
||||
: displayName}</div>
|
||||
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
|
||||
: displayName}</div>
|
||||
<div>
|
||||
<input
|
||||
.value=${row.label ?? ""}
|
||||
?disabled=${disabled}
|
||||
placeholder="(optional)"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>${row.kind}</div>
|
||||
@ -215,15 +212,15 @@ function renderRow(
|
||||
.value=${thinking}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, {
|
||||
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
|
||||
});
|
||||
}}
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, {
|
||||
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
|
||||
});
|
||||
}}
|
||||
>
|
||||
${thinkLevels.map((level) =>
|
||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||
)}
|
||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -231,13 +228,13 @@ function renderRow(
|
||||
.value=${verbose}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { verboseLevel: value || null });
|
||||
}}
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { verboseLevel: value || null });
|
||||
}}
|
||||
>
|
||||
${VERBOSE_LEVELS.map(
|
||||
(level) => html`<option value=${level.value}>${level.label}</option>`,
|
||||
)}
|
||||
(level) => html`<option value=${level.value}>${level.label}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -245,19 +242,19 @@ function renderRow(
|
||||
.value=${reasoning}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { reasoningLevel: value || null });
|
||||
}}
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { reasoningLevel: value || null });
|
||||
}}
|
||||
>
|
||||
${REASONING_LEVELS.map((level) =>
|
||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||
)}
|
||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||
<ui-button variant="danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||
Delete
|
||||
</button>
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user