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
|
# local tooling
|
||||||
.serena/
|
.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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Moltbot Control</title>
|
<title>Moltbot Control</title>
|
||||||
<meta name="color-scheme" content="dark light" />
|
<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" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<moltbot-app></moltbot-app>
|
<moltbot-app></moltbot-app>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
@import "./styles/base.css";
|
@import "./styles/base.css";
|
||||||
|
@import "./styles/color-themes.css";
|
||||||
@import "./styles/layout.css";
|
@import "./styles/layout.css";
|
||||||
@import "./styles/layout.mobile.css";
|
@import "./styles/layout.mobile.css";
|
||||||
@import "./styles/components.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 {
|
:root,
|
||||||
/* Background - Warmer dark with depth */
|
:root[data-color-theme="tangerine"] {
|
||||||
--bg: #12141a;
|
/* ============================================
|
||||||
--bg-accent: #14161d;
|
COLOR SYSTEM - DARK MODE (Default)
|
||||||
--bg-elevated: #1a1d25;
|
============================================ */
|
||||||
--bg-hover: #262a35;
|
|
||||||
--bg-muted: #262a35;
|
/* Background Colors - Muted Dark Theme */
|
||||||
|
--color-bg-primary: #1A1816;
|
||||||
/* Card / Surface - More contrast between levels */
|
--color-bg-secondary: #242220;
|
||||||
--card: #181b22;
|
--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-foreground: #f4f4f5;
|
||||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
--card-highlight: rgba(255, 159, 64, 0.03);
|
||||||
--popover: #181b22;
|
--popover: var(--color-bg-secondary);
|
||||||
--popover-foreground: #f4f4f5;
|
--popover-foreground: #f4f4f5;
|
||||||
|
|
||||||
/* Panel */
|
/* Panel */
|
||||||
--panel: #12141a;
|
--panel: var(--color-bg-primary);
|
||||||
--panel-strong: #1a1d25;
|
--panel-strong: var(--color-bg-elevated);
|
||||||
--panel-hover: #262a35;
|
--panel-hover: var(--color-bg-tertiary);
|
||||||
--chrome: rgba(18, 20, 26, 0.95);
|
--chrome: rgba(26, 24, 22, 0.95);
|
||||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
--chrome-strong: rgba(26, 24, 22, 0.98);
|
||||||
|
|
||||||
/* Text - Slightly warmer */
|
/* Text Colors - High Contrast */
|
||||||
--text: #e4e4e7;
|
--color-text-primary: #FFFFFF;
|
||||||
--text-strong: #fafafa;
|
--color-text-secondary: #E0E0E0;
|
||||||
--chat-text: #e4e4e7;
|
--color-text-tertiary: #B0B0B0;
|
||||||
--muted: #71717a;
|
--color-text-disabled: #666666;
|
||||||
--muted-strong: #52525b;
|
--color-text-inverse: #1A1A1A;
|
||||||
--muted-foreground: #71717a;
|
|
||||||
|
/* Legacy aliases */
|
||||||
/* Border - Subtle but defined */
|
--text: var(--color-text-secondary);
|
||||||
--border: #27272a;
|
--text-strong: var(--color-text-primary);
|
||||||
--border-strong: #3f3f46;
|
--chat-text: var(--color-text-secondary);
|
||||||
--border-hover: #52525b;
|
--muted: var(--color-text-tertiary);
|
||||||
--input: #27272a;
|
--muted-strong: #71717a;
|
||||||
--ring: #ff5c5c;
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
|
|
||||||
/* Accent - Punchy signature red */
|
/* Accent Colors - Tangerine Theme */
|
||||||
--accent: #ff5c5c;
|
--color-accent-primary: #FF9F40;
|
||||||
--accent-hover: #ff7070;
|
--color-accent-primary-hover: #FF8C2E;
|
||||||
--accent-muted: #ff5c5c;
|
--color-accent-primary-active: #FF7A1C;
|
||||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
--color-accent-secondary: #FFB366;
|
||||||
--accent-foreground: #fafafa;
|
|
||||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
/* Legacy aliases */
|
||||||
--primary: #ff5c5c;
|
--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;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
/* Secondary - Teal accent for variety */
|
/* Secondary Accent - Teal */
|
||||||
--secondary: #1e2028;
|
--secondary: var(--color-bg-secondary);
|
||||||
--secondary-foreground: #f4f4f5;
|
--secondary-foreground: #f4f4f5;
|
||||||
--accent-2: #14b8a6;
|
--accent-2: #14b8a6;
|
||||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
||||||
|
|
||||||
/* Semantic - More saturated */
|
/* Semantic Colors - Dark Mode */
|
||||||
--ok: #22c55e;
|
--color-accent-success: #4CAF50;
|
||||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
--color-accent-success-hover: #45A049;
|
||||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
--color-accent-error: #FF5252;
|
||||||
--destructive: #ef4444;
|
--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;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #f59e0b;
|
--warn: var(--color-accent-warning);
|
||||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
--warn-muted: rgba(255, 179, 0, 0.75);
|
||||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
--warn-subtle: rgba(255, 179, 0, 0.12);
|
||||||
--danger: #ef4444;
|
--danger: var(--color-accent-error);
|
||||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
--danger-muted: rgba(255, 82, 82, 0.75);
|
||||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
--danger-subtle: rgba(255, 82, 82, 0.12);
|
||||||
--info: #3b82f6;
|
--info: var(--color-accent-info);
|
||||||
|
|
||||||
/* Focus - With glow */
|
/* Border Colors - Dark Mode */
|
||||||
--focus: rgba(255, 92, 92, 0.25);
|
--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-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);
|
--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 */
|
||||||
--grid-line: rgba(255, 255, 255, 0.04);
|
--grid-line: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
/* Theme transition */
|
/* Theme transition */
|
||||||
--theme-switch-x: 50%;
|
--theme-switch-x: 50%;
|
||||||
--theme-switch-y: 50%;
|
--theme-switch-y: 50%;
|
||||||
|
|
||||||
/* Typography - Space Grotesk for personality */
|
/* ============================================
|
||||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
TYPOGRAPHY
|
||||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
============================================ */
|
||||||
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
|
/* Font Families */
|
||||||
/* Shadows - Richer with subtle color */
|
--font-family-base: 'Poppins', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--font-family-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||||
--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);
|
/* Legacy aliases */
|
||||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
--mono: var(--font-family-mono);
|
||||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
--font-body: var(--font-family-base);
|
||||||
|
--font-display: var(--font-family-base);
|
||||||
/* Radii - Slightly larger for friendlier feel */
|
|
||||||
|
/* 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-sm: 6px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--radius-xl: 16px;
|
--radius-xl: 16px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
|
|
||||||
/* Transitions - Snappy but smooth */
|
/* Shadows */
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--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-fast: 120ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 200ms;
|
||||||
--duration-slow: 350ms;
|
--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;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme - Clean with subtle warmth */
|
/* ============================================
|
||||||
:root[data-theme="light"] {
|
LIGHT MODE THEME
|
||||||
--bg: #fafafa;
|
============================================ */
|
||||||
--bg-accent: #f5f5f5;
|
|
||||||
--bg-elevated: #ffffff;
|
|
||||||
--bg-hover: #f0f0f0;
|
|
||||||
--bg-muted: #f0f0f0;
|
|
||||||
--bg-content: #f5f5f5;
|
|
||||||
|
|
||||||
--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-foreground: #18181b;
|
||||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
--card-highlight: rgba(255, 140, 66, 0.03);
|
||||||
--popover: #ffffff;
|
--popover: var(--color-bg-elevated);
|
||||||
--popover-foreground: #18181b;
|
--popover-foreground: #18181b;
|
||||||
|
|
||||||
--panel: #fafafa;
|
--panel: var(--color-bg-primary);
|
||||||
--panel-strong: #f5f5f5;
|
--panel-strong: var(--color-bg-secondary);
|
||||||
--panel-hover: #ebebeb;
|
--panel-hover: var(--color-bg-tertiary);
|
||||||
--chrome: rgba(250, 250, 250, 0.95);
|
--chrome: rgba(250, 250, 249, 0.95);
|
||||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
--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: #3f3f46;
|
||||||
--text-strong: #18181b;
|
--text-strong: var(--color-text-primary);
|
||||||
--chat-text: #3f3f46;
|
--chat-text: #3f3f46;
|
||||||
--muted: #71717a;
|
--muted: #71717a;
|
||||||
--muted-strong: #52525b;
|
--muted-strong: #52525b;
|
||||||
--muted-foreground: #71717a;
|
--muted-foreground: #71717a;
|
||||||
|
|
||||||
--border: #e4e4e7;
|
/* Accent Colors - Light Mode */
|
||||||
--border-strong: #d4d4d8;
|
--color-accent-primary: #FF8C42;
|
||||||
--border-hover: #a1a1aa;
|
--color-accent-primary-hover: #FF7A2E;
|
||||||
--input: #e4e4e7;
|
--color-accent-primary-active: #E5681A;
|
||||||
|
--color-accent-secondary: #FFA366;
|
||||||
--accent: #dc2626;
|
|
||||||
--accent-hover: #ef4444;
|
/* Legacy aliases */
|
||||||
--accent-muted: #dc2626;
|
--accent: var(--color-accent-primary);
|
||||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
--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-foreground: #ffffff;
|
||||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
--accent-glow: rgba(255, 140, 66, 0.15);
|
||||||
--primary: #dc2626;
|
--primary: var(--color-accent-primary);
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
--secondary: #f4f4f5;
|
--secondary: var(--color-bg-secondary);
|
||||||
--secondary-foreground: #3f3f46;
|
--secondary-foreground: #3f3f46;
|
||||||
--accent-2: #0d9488;
|
--accent-2: #0d9488;
|
||||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
||||||
|
|
||||||
--ok: #16a34a;
|
/* Semantic Colors - Light Mode */
|
||||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
--color-accent-success: #2E7D32;
|
||||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
--color-accent-success-hover: #1B5E20;
|
||||||
--destructive: #dc2626;
|
--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;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #d97706;
|
--warn: var(--color-accent-warning);
|
||||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
--warn-muted: rgba(245, 124, 0, 0.75);
|
||||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
--warn-subtle: rgba(245, 124, 0, 0.1);
|
||||||
--danger: #dc2626;
|
--danger: var(--color-accent-error);
|
||||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
--danger-muted: rgba(211, 47, 47, 0.75);
|
||||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
--danger-subtle: rgba(211, 47, 47, 0.1);
|
||||||
--info: #2563eb;
|
--info: var(--color-accent-info);
|
||||||
|
|
||||||
--focus: rgba(220, 38, 38, 0.2);
|
/* 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);
|
--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);
|
--grid-line: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
/* Light shadows */
|
/* Light shadows */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
--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-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-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-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);
|
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BASE STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@ -287,7 +445,7 @@ select {
|
|||||||
background: var(--border-strong);
|
background: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations - Polished with spring feel */
|
/* Animations */
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -350,14 +508,14 @@ select {
|
|||||||
|
|
||||||
@keyframes glow-pulse {
|
@keyframes glow-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
|
box-shadow: 0 0 0 rgba(255, 159, 64, 0);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 20px var(--accent-glow);
|
box-shadow: 0 0 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stagger animation delays for grouped elements */
|
/* Stagger animation delays */
|
||||||
.stagger-1 { animation-delay: 0ms; }
|
.stagger-1 { animation-delay: 0ms; }
|
||||||
.stagger-2 { animation-delay: 50ms; }
|
.stagger-2 { animation-delay: 50ms; }
|
||||||
.stagger-3 { animation-delay: 100ms; }
|
.stagger-3 { animation-delay: 100ms; }
|
||||||
@ -370,3 +528,26 @@ select {
|
|||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--focus-ring);
|
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;
|
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
|
Stats - Bold values, subtle labels
|
||||||
=========================================== */
|
=========================================== */
|
||||||
@ -774,6 +809,120 @@
|
|||||||
text-decoration: underline;
|
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
|
Log Stream
|
||||||
=========================================== */
|
=========================================== */
|
||||||
@ -1035,8 +1184,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-line.user .chat-bubble {
|
:root[data-theme="light"] .chat-line.user .chat-bubble {
|
||||||
border-color: rgba(234, 88, 12, 0.2);
|
border-color: rgba(255, 140, 66, 0.25);
|
||||||
background: rgba(251, 146, 60, 0.12);
|
background: rgba(255, 140, 66, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-line.assistant .chat-bubble {
|
.chat-line.assistant .chat-bubble {
|
||||||
@ -1291,6 +1440,95 @@
|
|||||||
background: var(--bg);
|
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 {
|
.chat-stamp {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@ -613,36 +613,38 @@
|
|||||||
|
|
||||||
.cfg-fields {
|
.cfg-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 22px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-field {
|
.cfg-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-field--error {
|
.cfg-field--error {
|
||||||
padding: 14px;
|
padding: 12px 14px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--danger-subtle);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-field__label {
|
.cfg-field__label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-field__help {
|
.cfg-field__help {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.45;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-field__error {
|
.cfg-field__error {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text Input */
|
/* Text Input */
|
||||||
@ -651,66 +653,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-input {
|
/* Textarea */
|
||||||
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 {
|
.cfg-textarea {
|
||||||
@ -743,135 +686,11 @@
|
|||||||
font-size: 12px;
|
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 */
|
/* Toggle Row */
|
||||||
.cfg-toggle-row {
|
.cfg-toggle-row {
|
||||||
@ -879,28 +698,31 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 16px 18px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-md);
|
||||||
background: var(--bg-accent);
|
background: var(--card);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background var(--duration-fast) ease,
|
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) {
|
.cfg-toggle-row:hover:not(.disabled) {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-toggle-row.disabled {
|
.cfg-toggle-row.disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .cfg-toggle-row {
|
:root[data-theme="light"] .cfg-toggle-row {
|
||||||
background: white;
|
background: var(--card);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
|
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
|
||||||
@ -914,77 +736,21 @@
|
|||||||
|
|
||||||
.cfg-toggle-row__label {
|
.cfg-toggle-row__label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-toggle-row__help {
|
.cfg-toggle-row__help {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 3px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
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) */
|
/* Object (collapsible) */
|
||||||
.cfg-object {
|
.cfg-object {
|
||||||
@ -1090,39 +856,7 @@
|
|||||||
background: white;
|
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 {
|
.cfg-array__help {
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
@ -1169,36 +903,7 @@
|
|||||||
letter-spacing: 0.05em;
|
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 {
|
.cfg-array__item-content {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@ -1231,35 +936,6 @@
|
|||||||
color: var(--muted);
|
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 {
|
.cfg-map__empty {
|
||||||
padding: 28px 18px;
|
padding: 28px 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -1288,32 +964,6 @@
|
|||||||
min-width: 0;
|
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 variants */
|
||||||
.pill--sm {
|
.pill--sm {
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
@ -1337,6 +987,8 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.config-layout {
|
.config-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
margin: -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-sidebar {
|
.config-sidebar {
|
||||||
@ -1345,22 +997,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-sidebar__header {
|
.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 {
|
.config-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 14px;
|
padding: 10px 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-nav__item {
|
.config-nav__item {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 9px 14px;
|
padding: 10px 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-height: 44px; /* Touch target */
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-nav__label {
|
.config-nav__label {
|
||||||
@ -1368,12 +1051,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-sidebar__footer {
|
.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 {
|
.config-actions {
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
padding: 14px 16px;
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-actions__left,
|
.config-actions__left,
|
||||||
@ -1382,30 +1078,122 @@
|
|||||||
justify-content: center;
|
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 {
|
.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 {
|
.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 {
|
.config-content {
|
||||||
padding: 18px;
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section cards */
|
||||||
|
.config-section-card {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section-card__header {
|
.config-section-card__header {
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-section-card__content {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cfg-toggle-row {
|
|
||||||
padding: 14px 16px;
|
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 {
|
.cfg-map__item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -1414,34 +1202,142 @@
|
|||||||
.cfg-map__item-remove {
|
.cfg-map__item-remove {
|
||||||
justify-self: end;
|
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) {
|
@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 {
|
.config-nav__icon {
|
||||||
width: 26px;
|
width: 24px;
|
||||||
height: 26px;
|
height: 24px;
|
||||||
font-size: 17px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-nav__label {
|
.config-nav__label {
|
||||||
display: none;
|
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 {
|
.config-section-card__icon {
|
||||||
width: 30px;
|
width: 26px;
|
||||||
height: 30px;
|
height: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section-card__title {
|
.config-section-card__title {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-segmented {
|
.config-section-card__content {
|
||||||
flex-wrap: wrap;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-segmented__btn {
|
.cfg-toggle-row {
|
||||||
flex: 1 0 auto;
|
padding: 12px;
|
||||||
min-width: 70px;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.config-diff {
|
||||||
|
margin: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-raw-field textarea {
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -164,6 +164,33 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
.topbar-status .pill .mono {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -197,6 +224,7 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
transition:
|
transition:
|
||||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||||
@ -206,7 +234,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav::-webkit-scrollbar {
|
.nav::-webkit-scrollbar {
|
||||||
display: none; /* Chrome/Safari */
|
display: none;
|
||||||
|
/* Chrome/Safari */
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell--chat-focus .nav {
|
.shell--chat-focus .nav {
|
||||||
@ -277,12 +306,16 @@
|
|||||||
/* Nav groups */
|
/* Nav groups */
|
||||||
.nav-group {
|
.nav-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group:last-child {
|
.nav-group:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group__items {
|
.nav-group__items {
|
||||||
@ -417,7 +450,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
padding: 12px 16px 32px;
|
padding: 32px 16px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@ -442,14 +475,15 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
overflow: hidden;
|
margin-bottom: 24px;
|
||||||
|
overflow: visible;
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
transition:
|
transition:
|
||||||
opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
||||||
transform 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),
|
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||||
padding 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 {
|
.shell--chat-focus .content-header {
|
||||||
@ -489,7 +523,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content--chat .content-header > div:first-child {
|
.content--chat .content-header>div:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -606,4 +640,4 @@
|
|||||||
.list-item {
|
.list-item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,6 +61,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide nav toggle button on mobile (sidebar is always visible/stacked) */
|
||||||
|
.nav-collapse-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -80,6 +85,43 @@
|
|||||||
flex-wrap: nowrap;
|
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 {
|
.topbar-status .pill {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -214,9 +256,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat thread - ensure proper scrolling on mobile */
|
||||||
.chat-thread {
|
.chat-thread {
|
||||||
margin-top: 8px;
|
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 {
|
.chat-msg {
|
||||||
@ -228,15 +274,47 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat compose - fixed at bottom on mobile */
|
||||||
.chat-compose {
|
.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;
|
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 {
|
.chat-compose__field textarea {
|
||||||
min-height: 60px;
|
min-height: 44px;
|
||||||
|
max-height: 120px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-md);
|
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 */
|
/* Log stream */
|
||||||
@ -315,6 +393,35 @@
|
|||||||
font-size: 13px;
|
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 {
|
.nav {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
@ -345,10 +452,16 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-compose {
|
||||||
|
padding: 10px 6px;
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
.chat-compose__field textarea {
|
.chat-compose__field textarea {
|
||||||
min-height: 52px;
|
min-height: 40px;
|
||||||
|
max-height: 100px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
font-size: 13px;
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@ -372,3 +485,10 @@
|
|||||||
height: 11px;
|
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,
|
syncTabWithLocation,
|
||||||
syncThemeWithSettings,
|
syncThemeWithSettings,
|
||||||
} from "./app-settings";
|
} from "./app-settings";
|
||||||
|
import { applyColorTheme } from "./theme";
|
||||||
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
import {
|
import {
|
||||||
startLogsPolling,
|
startLogsPolling,
|
||||||
@ -31,6 +32,7 @@ type LifecycleHost = {
|
|||||||
logsEntries: unknown[];
|
logsEntries: unknown[];
|
||||||
popStateHandler: () => void;
|
popStateHandler: () => void;
|
||||||
topbarObserver: ResizeObserver | null;
|
topbarObserver: ResizeObserver | null;
|
||||||
|
settings: { colorTheme: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function handleConnected(host: LifecycleHost) {
|
export function handleConnected(host: LifecycleHost) {
|
||||||
@ -42,6 +44,10 @@ export function handleConnected(host: LifecycleHost) {
|
|||||||
syncThemeWithSettings(
|
syncThemeWithSettings(
|
||||||
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
||||||
);
|
);
|
||||||
|
// Apply saved color theme
|
||||||
|
if (host.settings?.colorTheme) {
|
||||||
|
applyColorTheme(host.settings.colorTheme as any);
|
||||||
|
}
|
||||||
attachThemeListener(
|
attachThemeListener(
|
||||||
host as unknown as Parameters<typeof attachThemeListener>[0],
|
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -79,6 +79,10 @@ import {
|
|||||||
saveExecApprovals,
|
saveExecApprovals,
|
||||||
updateExecApprovalsFormValue,
|
updateExecApprovalsFormValue,
|
||||||
} from "./controllers/exec-approvals";
|
} 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 { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
@ -119,10 +123,10 @@ export function renderApp(state: AppViewState) {
|
|||||||
<button
|
<button
|
||||||
class="nav-collapse-toggle"
|
class="nav-collapse-toggle"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navCollapsed: !state.settings.navCollapsed,
|
navCollapsed: !state.settings.navCollapsed,
|
||||||
})}
|
})}
|
||||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||||
aria-label="${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>
|
</div>
|
||||||
<div class="topbar-status">
|
<div class="topbar-status">
|
||||||
<div class="pill">
|
<div class="health-badge">
|
||||||
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
||||||
<span>Health</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>
|
</div>
|
||||||
${renderThemeToggle(state)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||||
${TAB_GROUPS.map((group) => {
|
${TAB_GROUPS.map((group) => {
|
||||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||||
return html`
|
return html`
|
||||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||||
<button
|
<button
|
||||||
class="nav-label"
|
class="nav-label"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...state.settings.navGroupsCollapsed };
|
const next = { ...state.settings.navGroupsCollapsed };
|
||||||
next[group.label] = !isGroupCollapsed;
|
next[group.label] = !isGroupCollapsed;
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navGroupsCollapsed: next,
|
navGroupsCollapsed: next,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-expanded=${!isGroupCollapsed}
|
aria-expanded=${!isGroupCollapsed}
|
||||||
>
|
>
|
||||||
<span class="nav-label__text">${group.label}</span>
|
<span class="nav-label__text">${group.label}</span>
|
||||||
@ -173,7 +188,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
<div class="nav-group nav-group--links">
|
<div class="nav-group nav-group--links">
|
||||||
<div class="nav-label nav-label--static">
|
<div class="nav-label nav-label--static">
|
||||||
<span class="nav-label__text">Resources</span>
|
<span class="nav-label__text">Resources</span>
|
||||||
@ -200,383 +215,383 @@ export function renderApp(state: AppViewState) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
${state.lastError
|
${state.lastError
|
||||||
? html`<div class="pill danger">${state.lastError}</div>`
|
? html`<ui-badge variant="destructive">${state.lastError}</ui-badge>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${isChat ? renderChatControls(state) : nothing}
|
${isChat ? renderChatControls(state) : nothing}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
${state.tab === "overview"
|
${state.tab === "overview"
|
||||||
? renderOverview({
|
? renderOverview({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
hello: state.hello,
|
hello: state.hello,
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
password: state.password,
|
password: state.password,
|
||||||
lastError: state.lastError,
|
lastError: state.lastError,
|
||||||
presenceCount,
|
presenceCount,
|
||||||
sessionsCount,
|
sessionsCount,
|
||||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||||
cronNext,
|
cronNext,
|
||||||
lastChannelsRefresh: state.channelsLastSuccess,
|
lastChannelsRefresh: state.channelsLastSuccess,
|
||||||
onSettingsChange: (next) => state.applySettings(next),
|
onSettingsChange: (next) => state.applySettings(next),
|
||||||
onPasswordChange: (next) => (state.password = next),
|
onPasswordChange: (next) => (state.password = next),
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
lastActiveSessionKey: next,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
},
|
},
|
||||||
onConnect: () => state.connect(),
|
onConnect: () => state.connect(),
|
||||||
onRefresh: () => state.loadOverview(),
|
onRefresh: () => state.loadOverview(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "channels"
|
${state.tab === "channels"
|
||||||
? renderChannels({
|
? renderChannels({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
loading: state.channelsLoading,
|
loading: state.channelsLoading,
|
||||||
snapshot: state.channelsSnapshot,
|
snapshot: state.channelsSnapshot,
|
||||||
lastError: state.channelsError,
|
lastError: state.channelsError,
|
||||||
lastSuccessAt: state.channelsLastSuccess,
|
lastSuccessAt: state.channelsLastSuccess,
|
||||||
whatsappMessage: state.whatsappLoginMessage,
|
whatsappMessage: state.whatsappLoginMessage,
|
||||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||||
whatsappConnected: state.whatsappLoginConnected,
|
whatsappConnected: state.whatsappLoginConnected,
|
||||||
whatsappBusy: state.whatsappBusy,
|
whatsappBusy: state.whatsappBusy,
|
||||||
configSchema: state.configSchema,
|
configSchema: state.configSchema,
|
||||||
configSchemaLoading: state.configSchemaLoading,
|
configSchemaLoading: state.configSchemaLoading,
|
||||||
configForm: state.configForm,
|
configForm: state.configForm,
|
||||||
configUiHints: state.configUiHints,
|
configUiHints: state.configUiHints,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configFormDirty: state.configFormDirty,
|
configFormDirty: state.configFormDirty,
|
||||||
nostrProfileFormState: state.nostrProfileFormState,
|
nostrProfileFormState: state.nostrProfileFormState,
|
||||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||||
onRefresh: (probe) => loadChannels(state, probe),
|
onRefresh: (probe) => loadChannels(state, probe),
|
||||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onConfigSave: () => state.handleChannelConfigSave(),
|
onConfigSave: () => state.handleChannelConfigSave(),
|
||||||
onConfigReload: () => state.handleChannelConfigReload(),
|
onConfigReload: () => state.handleChannelConfigReload(),
|
||||||
onNostrProfileEdit: (accountId, profile) =>
|
onNostrProfileEdit: (accountId, profile) =>
|
||||||
state.handleNostrProfileEdit(accountId, profile),
|
state.handleNostrProfileEdit(accountId, profile),
|
||||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||||
onNostrProfileFieldChange: (field, value) =>
|
onNostrProfileFieldChange: (field, value) =>
|
||||||
state.handleNostrProfileFieldChange(field, value),
|
state.handleNostrProfileFieldChange(field, value),
|
||||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "instances"
|
${state.tab === "instances"
|
||||||
? renderInstances({
|
? renderInstances({
|
||||||
loading: state.presenceLoading,
|
loading: state.presenceLoading,
|
||||||
entries: state.presenceEntries,
|
entries: state.presenceEntries,
|
||||||
lastError: state.presenceError,
|
lastError: state.presenceError,
|
||||||
statusMessage: state.presenceStatus,
|
statusMessage: state.presenceStatus,
|
||||||
onRefresh: () => loadPresence(state),
|
onRefresh: () => loadPresence(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "sessions"
|
${state.tab === "sessions"
|
||||||
? renderSessions({
|
? renderSessions({
|
||||||
loading: state.sessionsLoading,
|
loading: state.sessionsLoading,
|
||||||
result: state.sessionsResult,
|
result: state.sessionsResult,
|
||||||
error: state.sessionsError,
|
error: state.sessionsError,
|
||||||
activeMinutes: state.sessionsFilterActive,
|
activeMinutes: state.sessionsFilterActive,
|
||||||
limit: state.sessionsFilterLimit,
|
limit: state.sessionsFilterLimit,
|
||||||
includeGlobal: state.sessionsIncludeGlobal,
|
includeGlobal: state.sessionsIncludeGlobal,
|
||||||
includeUnknown: state.sessionsIncludeUnknown,
|
includeUnknown: state.sessionsIncludeUnknown,
|
||||||
basePath: state.basePath,
|
basePath: state.basePath,
|
||||||
onFiltersChange: (next) => {
|
onFiltersChange: (next) => {
|
||||||
state.sessionsFilterActive = next.activeMinutes;
|
state.sessionsFilterActive = next.activeMinutes;
|
||||||
state.sessionsFilterLimit = next.limit;
|
state.sessionsFilterLimit = next.limit;
|
||||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||||
},
|
},
|
||||||
onRefresh: () => loadSessions(state),
|
onRefresh: () => loadSessions(state),
|
||||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||||
onDelete: (key) => deleteSession(state, key),
|
onDelete: (key) => deleteSession(state, key),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "cron"
|
${state.tab === "cron"
|
||||||
? renderCron({
|
? renderCron({
|
||||||
loading: state.cronLoading,
|
loading: state.cronLoading,
|
||||||
status: state.cronStatus,
|
status: state.cronStatus,
|
||||||
jobs: state.cronJobs,
|
jobs: state.cronJobs,
|
||||||
error: state.cronError,
|
error: state.cronError,
|
||||||
busy: state.cronBusy,
|
busy: state.cronBusy,
|
||||||
form: state.cronForm,
|
form: state.cronForm,
|
||||||
channels: state.channelsSnapshot?.channelMeta?.length
|
channels: state.channelsSnapshot?.channelMeta?.length
|
||||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||||
: state.channelsSnapshot?.channelOrder ?? [],
|
: state.channelsSnapshot?.channelOrder ?? [],
|
||||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||||
runsJobId: state.cronRunsJobId,
|
runsJobId: state.cronRunsJobId,
|
||||||
runs: state.cronRuns,
|
runs: state.cronRuns,
|
||||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||||
onRefresh: () => state.loadCron(),
|
onRefresh: () => state.loadCron(),
|
||||||
onAdd: () => addCronJob(state),
|
onAdd: () => addCronJob(state),
|
||||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||||
onRun: (job) => runCronJob(state, job),
|
onRun: (job) => runCronJob(state, job),
|
||||||
onRemove: (job) => removeCronJob(state, job),
|
onRemove: (job) => removeCronJob(state, job),
|
||||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "skills"
|
${state.tab === "skills"
|
||||||
? renderSkills({
|
? renderSkills({
|
||||||
loading: state.skillsLoading,
|
loading: state.skillsLoading,
|
||||||
report: state.skillsReport,
|
report: state.skillsReport,
|
||||||
error: state.skillsError,
|
error: state.skillsError,
|
||||||
filter: state.skillsFilter,
|
filter: state.skillsFilter,
|
||||||
edits: state.skillEdits,
|
edits: state.skillEdits,
|
||||||
messages: state.skillMessages,
|
messages: state.skillMessages,
|
||||||
busyKey: state.skillsBusyKey,
|
busyKey: state.skillsBusyKey,
|
||||||
onFilterChange: (next) => (state.skillsFilter = next),
|
onFilterChange: (next) => (state.skillsFilter = next),
|
||||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||||
onInstall: (skillKey, name, installId) =>
|
onInstall: (skillKey, name, installId) =>
|
||||||
installSkill(state, skillKey, name, installId),
|
installSkill(state, skillKey, name, installId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "nodes"
|
${state.tab === "nodes"
|
||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
devicesLoading: state.devicesLoading,
|
devicesLoading: state.devicesLoading,
|
||||||
devicesError: state.devicesError,
|
devicesError: state.devicesError,
|
||||||
devicesList: state.devicesList,
|
devicesList: state.devicesList,
|
||||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||||
configLoading: state.configLoading,
|
configLoading: state.configLoading,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configDirty: state.configFormDirty,
|
configDirty: state.configFormDirty,
|
||||||
configFormMode: state.configFormMode,
|
configFormMode: state.configFormMode,
|
||||||
execApprovalsLoading: state.execApprovalsLoading,
|
execApprovalsLoading: state.execApprovalsLoading,
|
||||||
execApprovalsSaving: state.execApprovalsSaving,
|
execApprovalsSaving: state.execApprovalsSaving,
|
||||||
execApprovalsDirty: state.execApprovalsDirty,
|
execApprovalsDirty: state.execApprovalsDirty,
|
||||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||||
execApprovalsForm: state.execApprovalsForm,
|
execApprovalsForm: state.execApprovalsForm,
|
||||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||||
execApprovalsTarget: state.execApprovalsTarget,
|
execApprovalsTarget: state.execApprovalsTarget,
|
||||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||||
onRefresh: () => loadNodes(state),
|
onRefresh: () => loadNodes(state),
|
||||||
onDevicesRefresh: () => loadDevices(state),
|
onDevicesRefresh: () => loadDevices(state),
|
||||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||||
onDeviceRotate: (deviceId, role, scopes) =>
|
onDeviceRotate: (deviceId, role, scopes) =>
|
||||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||||
onDeviceRevoke: (deviceId, role) =>
|
onDeviceRevoke: (deviceId, role) =>
|
||||||
revokeDeviceToken(state, { deviceId, role }),
|
revokeDeviceToken(state, { deviceId, role }),
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => {
|
onLoadExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||||
: { kind: "gateway" as const };
|
: { kind: "gateway" as const };
|
||||||
return loadExecApprovals(state, target);
|
return loadExecApprovals(state, target);
|
||||||
},
|
},
|
||||||
onBindDefault: (nodeId) => {
|
onBindDefault: (nodeId) => {
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBindAgent: (agentIndex, nodeId) => {
|
onBindAgent: (agentIndex, nodeId) => {
|
||||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, basePath, nodeId);
|
updateConfigFormValue(state, basePath, nodeId);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state, basePath);
|
removeConfigFormValue(state, basePath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveBindings: () => saveConfig(state),
|
onSaveBindings: () => saveConfig(state),
|
||||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||||
state.execApprovalsTarget = kind;
|
state.execApprovalsTarget = kind;
|
||||||
state.execApprovalsTargetNodeId = nodeId;
|
state.execApprovalsTargetNodeId = nodeId;
|
||||||
state.execApprovalsSnapshot = null;
|
state.execApprovalsSnapshot = null;
|
||||||
state.execApprovalsForm = null;
|
state.execApprovalsForm = null;
|
||||||
state.execApprovalsDirty = false;
|
state.execApprovalsDirty = false;
|
||||||
state.execApprovalsSelectedAgent = null;
|
state.execApprovalsSelectedAgent = null;
|
||||||
},
|
},
|
||||||
onExecApprovalsSelectAgent: (agentId) => {
|
onExecApprovalsSelectAgent: (agentId) => {
|
||||||
state.execApprovalsSelectedAgent = agentId;
|
state.execApprovalsSelectedAgent = agentId;
|
||||||
},
|
},
|
||||||
onExecApprovalsPatch: (path, value) =>
|
onExecApprovalsPatch: (path, value) =>
|
||||||
updateExecApprovalsFormValue(state, path, value),
|
updateExecApprovalsFormValue(state, path, value),
|
||||||
onExecApprovalsRemove: (path) =>
|
onExecApprovalsRemove: (path) =>
|
||||||
removeExecApprovalsFormValue(state, path),
|
removeExecApprovalsFormValue(state, path),
|
||||||
onSaveExecApprovals: () => {
|
onSaveExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||||
: { kind: "gateway" as const };
|
: { kind: "gateway" as const };
|
||||||
return saveExecApprovals(state, target);
|
return saveExecApprovals(state, target);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "chat"
|
${state.tab === "chat"
|
||||||
? renderChat({
|
? renderChat({
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.chatAttachments = [];
|
state.chatAttachments = [];
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatStreamStartedAt = null;
|
state.chatStreamStartedAt = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
state.chatQueue = [];
|
state.chatQueue = [];
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
state.resetChatScroll();
|
state.resetChatScroll();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
lastActiveSessionKey: next,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
void loadChatHistory(state);
|
void loadChatHistory(state);
|
||||||
void refreshChatAvatar(state);
|
void refreshChatAvatar(state);
|
||||||
},
|
},
|
||||||
thinkingLevel: state.chatThinkingLevel,
|
thinkingLevel: state.chatThinkingLevel,
|
||||||
showThinking,
|
showThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
compactionStatus: state.compactionStatus,
|
compactionStatus: state.compactionStatus,
|
||||||
assistantAvatarUrl: chatAvatarUrl,
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
streamStartedAt: state.chatStreamStartedAt,
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
queue: state.chatQueue,
|
queue: state.chatQueue,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
canSend: state.connected,
|
canSend: state.connected,
|
||||||
disabledReason: chatDisabledReason,
|
disabledReason: chatDisabledReason,
|
||||||
error: state.lastError,
|
error: state.lastError,
|
||||||
sessions: state.sessionsResult,
|
sessions: state.sessionsResult,
|
||||||
focusMode: chatFocus,
|
focusMode: chatFocus,
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||||
},
|
},
|
||||||
onToggleFocusMode: () => {
|
onToggleFocusMode: () => {
|
||||||
if (state.onboarding) return;
|
if (state.onboarding) return;
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
chatFocusMode: !state.settings.chatFocusMode,
|
chatFocusMode: !state.settings.chatFocusMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChatScroll: (event) => state.handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
attachments: state.chatAttachments,
|
onSend: () => state.handleSendChat(),
|
||||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onSend: () => state.handleSendChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
attachments: state.chatAttachments,
|
||||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
onNewSession: () =>
|
onNewSession: () =>
|
||||||
state.handleSendChat("/new", { restoreDraft: true }),
|
state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
// Sidebar props for tool output viewing
|
// Sidebar props for tool output viewing
|
||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
sidebarContent: state.sidebarContent,
|
sidebarContent: state.sidebarContent,
|
||||||
sidebarError: state.sidebarError,
|
sidebarError: state.sidebarError,
|
||||||
splitRatio: state.splitRatio,
|
splitRatio: state.splitRatio,
|
||||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||||
assistantName: state.assistantName,
|
assistantName: state.assistantName,
|
||||||
assistantAvatar: state.assistantAvatar,
|
assistantAvatar: state.assistantAvatar,
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "config"
|
${state.tab === "config"
|
||||||
? renderConfig({
|
? renderConfig({
|
||||||
raw: state.configRaw,
|
raw: state.configRaw,
|
||||||
originalRaw: state.configRawOriginal,
|
originalRaw: state.configRawOriginal,
|
||||||
valid: state.configValid,
|
valid: state.configValid,
|
||||||
issues: state.configIssues,
|
issues: state.configIssues,
|
||||||
loading: state.configLoading,
|
loading: state.configLoading,
|
||||||
saving: state.configSaving,
|
saving: state.configSaving,
|
||||||
applying: state.configApplying,
|
applying: state.configApplying,
|
||||||
updating: state.updateRunning,
|
updating: state.updateRunning,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
schema: state.configSchema,
|
schema: state.configSchema,
|
||||||
schemaLoading: state.configSchemaLoading,
|
schemaLoading: state.configSchemaLoading,
|
||||||
uiHints: state.configUiHints,
|
uiHints: state.configUiHints,
|
||||||
formMode: state.configFormMode,
|
formMode: state.configFormMode,
|
||||||
formValue: state.configForm,
|
formValue: state.configForm,
|
||||||
originalValue: state.configFormOriginal,
|
originalValue: state.configFormOriginal,
|
||||||
searchQuery: state.configSearchQuery,
|
searchQuery: state.configSearchQuery,
|
||||||
activeSection: state.configActiveSection,
|
activeSection: state.configActiveSection,
|
||||||
activeSubsection: state.configActiveSubsection,
|
activeSubsection: state.configActiveSubsection,
|
||||||
onRawChange: (next) => {
|
onRawChange: (next) => {
|
||||||
state.configRaw = next;
|
state.configRaw = next;
|
||||||
},
|
},
|
||||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||||
onSectionChange: (section) => {
|
onSectionChange: (section) => {
|
||||||
state.configActiveSection = section;
|
state.configActiveSection = section;
|
||||||
state.configActiveSubsection = null;
|
state.configActiveSubsection = null;
|
||||||
},
|
},
|
||||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||||
onReload: () => loadConfig(state),
|
onReload: () => loadConfig(state),
|
||||||
onSave: () => saveConfig(state),
|
onSave: () => saveConfig(state),
|
||||||
onApply: () => applyConfig(state),
|
onApply: () => applyConfig(state),
|
||||||
onUpdate: () => runUpdate(state),
|
onUpdate: () => runUpdate(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "debug"
|
${state.tab === "debug"
|
||||||
? renderDebug({
|
? renderDebug({
|
||||||
loading: state.debugLoading,
|
loading: state.debugLoading,
|
||||||
status: state.debugStatus,
|
status: state.debugStatus,
|
||||||
health: state.debugHealth,
|
health: state.debugHealth,
|
||||||
models: state.debugModels,
|
models: state.debugModels,
|
||||||
heartbeat: state.debugHeartbeat,
|
heartbeat: state.debugHeartbeat,
|
||||||
eventLog: state.eventLog,
|
eventLog: state.eventLog,
|
||||||
callMethod: state.debugCallMethod,
|
callMethod: state.debugCallMethod,
|
||||||
callParams: state.debugCallParams,
|
callParams: state.debugCallParams,
|
||||||
callResult: state.debugCallResult,
|
callResult: state.debugCallResult,
|
||||||
callError: state.debugCallError,
|
callError: state.debugCallError,
|
||||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||||
onRefresh: () => loadDebug(state),
|
onRefresh: () => loadDebug(state),
|
||||||
onCall: () => callDebugMethod(state),
|
onCall: () => callDebugMethod(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "logs"
|
${state.tab === "logs"
|
||||||
? renderLogs({
|
? renderLogs({
|
||||||
loading: state.logsLoading,
|
loading: state.logsLoading,
|
||||||
error: state.logsError,
|
error: state.logsError,
|
||||||
file: state.logsFile,
|
file: state.logsFile,
|
||||||
entries: state.logsEntries,
|
entries: state.logsEntries,
|
||||||
filterText: state.logsFilterText,
|
filterText: state.logsFilterText,
|
||||||
levelFilters: state.logsLevelFilters,
|
levelFilters: state.logsLevelFilters,
|
||||||
autoFollow: state.logsAutoFollow,
|
autoFollow: state.logsAutoFollow,
|
||||||
truncated: state.logsTruncated,
|
truncated: state.logsTruncated,
|
||||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||||
onLevelToggle: (level, enabled) => {
|
onLevelToggle: (level, enabled) => {
|
||||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||||
},
|
},
|
||||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||||
onRefresh: () => loadLogs(state, { reset: true }),
|
onRefresh: () => loadLogs(state, { reset: true }),
|
||||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||||
onScroll: (event) => state.handleLogsScroll(event),
|
onScroll: (event) => state.handleLogsScroll(event),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
</main>
|
</main>
|
||||||
${renderExecApprovalPrompt(state)}
|
${renderExecApprovalPrompt(state)}
|
||||||
${renderGatewayUrlConfirmation(state)}
|
${renderGatewayUrlConfirmation(state)}
|
||||||
|
|||||||
@ -200,6 +200,16 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.dataset.theme = resolved;
|
root.dataset.theme = resolved;
|
||||||
root.style.colorScheme = 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) {
|
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() {
|
async loadOverview() {
|
||||||
await loadOverviewInternal(
|
await loadOverviewInternal(
|
||||||
this as unknown as Parameters<typeof loadOverviewInternal>[0],
|
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) {
|
:host(.dragging) {
|
||||||
background: var(--accent, #007bff);
|
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() {
|
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) {
|
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
const confirmed = window.confirm("Reject this device pairing request?");
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
try {
|
||||||
await state.client.request("device.pair.reject", { requestId });
|
await state.client.request("device.pair.reject", { requestId });
|
||||||
await loadDevices(state);
|
await loadDevices(state);
|
||||||
@ -118,10 +116,6 @@ export async function revokeDeviceToken(
|
|||||||
params: { deviceId: string; role: string },
|
params: { deviceId: string; role: string },
|
||||||
) {
|
) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
const confirmed = window.confirm(
|
|
||||||
`Revoke token for ${params.deviceId} (${params.role})?`,
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
try {
|
||||||
await state.client.request("device.token.revoke", params);
|
await state.client.request("device.token.revoke", params);
|
||||||
const identity = await loadOrCreateDeviceIdentity();
|
const identity = await loadOrCreateDeviceIdentity();
|
||||||
|
|||||||
@ -66,10 +66,6 @@ export async function patchSession(
|
|||||||
export async function deleteSession(state: SessionsState, key: string) {
|
export async function deleteSession(state: SessionsState, key: string) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
if (state.sessionsLoading) 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.sessionsLoading = true;
|
||||||
state.sessionsError = null;
|
state.sessionsError = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const icons = {
|
|||||||
|
|
||||||
// UI 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>`,
|
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>`,
|
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>`,
|
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>`,
|
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";
|
const KEY = "moltbot.control.settings.v1";
|
||||||
|
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode, ColorTheme } from "./theme";
|
||||||
|
|
||||||
export type UiSettings = {
|
export type UiSettings = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@ -8,6 +8,7 @@ export type UiSettings = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
lastActiveSessionKey: string;
|
lastActiveSessionKey: string;
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
chatFocusMode: boolean;
|
chatFocusMode: boolean;
|
||||||
chatShowThinking: boolean;
|
chatShowThinking: boolean;
|
||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||||
@ -27,6 +28,7 @@ export function loadSettings(): UiSettings {
|
|||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
lastActiveSessionKey: "main",
|
lastActiveSessionKey: "main",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
|
colorTheme: "tangerine",
|
||||||
chatFocusMode: false,
|
chatFocusMode: false,
|
||||||
chatShowThinking: true,
|
chatShowThinking: true,
|
||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
@ -61,6 +63,15 @@ export function loadSettings(): UiSettings {
|
|||||||
parsed.theme === "system"
|
parsed.theme === "system"
|
||||||
? parsed.theme
|
? parsed.theme
|
||||||
: defaults.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:
|
chatFocusMode:
|
||||||
typeof parsed.chatFocusMode === "boolean"
|
typeof parsed.chatFocusMode === "boolean"
|
||||||
? parsed.chatFocusMode
|
? parsed.chatFocusMode
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
export type ThemeMode = "system" | "light" | "dark";
|
export type ThemeMode = "system" | "light" | "dark";
|
||||||
export type ResolvedTheme = "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 {
|
export function getSystemTheme(): ResolvedTheme {
|
||||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||||
@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
|||||||
if (mode === "system") return getSystemTheme();
|
if (mode === "system") return getSystemTheme();
|
||||||
return mode;
|
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 { channelId, props } = params;
|
||||||
const disabled = props.configSaving || props.configSchemaLoading;
|
const disabled = props.configSaving || props.configSchemaLoading;
|
||||||
return html`
|
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
|
${props.configSchemaLoading
|
||||||
? html`<div class="muted">Loading config schema…</div>`
|
? html`<div class="muted">Loading config schema…</div>`
|
||||||
: renderChannelConfigForm({
|
: renderChannelConfigForm({
|
||||||
@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: {
|
|||||||
disabled,
|
disabled,
|
||||||
onPatch: props.onConfigPatch,
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,51 +11,71 @@ export function renderDiscordCard(params: {
|
|||||||
accountCountLabel: unknown;
|
accountCountLabel: unknown;
|
||||||
}) {
|
}) {
|
||||||
const { props, discord, accountCountLabel } = params;
|
const { props, discord, accountCountLabel } = params;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Discord</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Bot status and channel configuration.</div>
|
<div class="card-title">Discord</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Bot status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${discord?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${discord?.lastError
|
||||||
${discord.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${discord.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${discord?.probe
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
Probe
|
||||||
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
|
</ui-button>
|
||||||
</div>`
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
: nothing}
|
<ui-button
|
||||||
|
variant="primary"
|
||||||
${renderChannelConfigSection({ channelId: "discord", props })}
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
|
@click=${() => props.onConfigSave()}
|
||||||
<div class="row" style="margin-top: 12px;">
|
>
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
Probe
|
</ui-button>
|
||||||
</button>
|
<ui-button
|
||||||
|
?disabled=${configDisabled}
|
||||||
|
@click=${() => props.onConfigReload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -11,63 +11,83 @@ export function renderGoogleChatCard(params: {
|
|||||||
accountCountLabel: unknown;
|
accountCountLabel: unknown;
|
||||||
}) {
|
}) {
|
||||||
const { props, googleChat, accountCountLabel } = params;
|
const { props, googleChat, accountCountLabel } = params;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Google Chat</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Chat API webhook status and channel configuration.</div>
|
<div class="card-title">Google Chat</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Chat API webhook status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
|
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
|
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Credential</span>
|
<span class="label">Credential</span>
|
||||||
<span>${googleChat?.credentialSource ?? "n/a"}</span>
|
<span>${googleChat?.credentialSource ?? "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Audience</span>
|
<span class="label">Audience</span>
|
||||||
<span>
|
<span>
|
||||||
${googleChat?.audienceType
|
${googleChat?.audienceType
|
||||||
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
|
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
|
||||||
: "n/a"}
|
: "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${googleChat?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${googleChat?.lastError
|
||||||
${googleChat.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${googleChat.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${googleChat?.probe
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
|
Probe
|
||||||
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
|
</ui-button>
|
||||||
</div>`
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
: nothing}
|
<ui-button
|
||||||
|
variant="primary"
|
||||||
${renderChannelConfigSection({ channelId: "googlechat", props })}
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
|
@click=${() => props.onConfigSave()}
|
||||||
<div class="row" style="margin-top: 12px;">
|
>
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
Probe
|
</ui-button>
|
||||||
</button>
|
<ui-button
|
||||||
|
?disabled=${configDisabled}
|
||||||
|
@click=${() => props.onConfigReload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -11,51 +11,71 @@ export function renderIMessageCard(params: {
|
|||||||
accountCountLabel: unknown;
|
accountCountLabel: unknown;
|
||||||
}) {
|
}) {
|
||||||
const { props, imessage, accountCountLabel } = params;
|
const { props, imessage, accountCountLabel } = params;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">iMessage</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
<div class="card-title">iMessage</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${imessage?.running ? "Yes" : "No"}</span>
|
<span>${imessage?.running ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${imessage?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${imessage?.lastError
|
||||||
${imessage.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${imessage.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${imessage?.probe
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
|
Probe
|
||||||
${imessage.probe.error ?? ""}
|
</ui-button>
|
||||||
</div>`
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
: nothing}
|
<ui-button
|
||||||
|
variant="primary"
|
||||||
${renderChannelConfigSection({ channelId: "imessage", props })}
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
|
@click=${() => props.onConfigSave()}
|
||||||
<div class="row" style="margin-top: 12px;">
|
>
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
Probe
|
</ui-button>
|
||||||
</button>
|
<ui-button
|
||||||
|
?disabled=${configDisabled}
|
||||||
|
@click=${() => props.onConfigReload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -235,36 +235,33 @@ export function renderNostrProfileForm(params: {
|
|||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
|
||||||
<button
|
<ui-button
|
||||||
class="btn primary"
|
variant="primary"
|
||||||
@click=${callbacks.onSave}
|
@click=${callbacks.onSave}
|
||||||
?disabled=${state.saving || !isDirty}
|
?disabled=${state.saving || !isDirty}
|
||||||
>
|
>
|
||||||
${state.saving ? "Saving..." : "Save & Publish"}
|
${state.saving ? "Saving..." : "Save & Publish"}
|
||||||
</button>
|
</ui-button>
|
||||||
|
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
|
||||||
@click=${callbacks.onImport}
|
@click=${callbacks.onImport}
|
||||||
?disabled=${state.importing || state.saving}
|
?disabled=${state.importing || state.saving}
|
||||||
>
|
>
|
||||||
${state.importing ? "Importing..." : "Import from Relays"}
|
${state.importing ? "Importing..." : "Import from Relays"}
|
||||||
</button>
|
</ui-button>
|
||||||
|
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
|
||||||
@click=${callbacks.onToggleAdvanced}
|
@click=${callbacks.onToggleAdvanced}
|
||||||
>
|
>
|
||||||
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||||
</button>
|
</ui-button>
|
||||||
|
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
|
||||||
@click=${callbacks.onCancel}
|
@click=${callbacks.onCancel}
|
||||||
?disabled=${state.saving}
|
?disabled=${state.saving}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${isDirty
|
${isDirty
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export function renderNostrCard(params: {
|
|||||||
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
|
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
|
||||||
const hasMultipleAccounts = nostrAccounts.length > 1;
|
const hasMultipleAccounts = nostrAccounts.length > 1;
|
||||||
const showingForm = profileFormState !== null && profileFormState !== undefined;
|
const showingForm = profileFormState !== null && profileFormState !== undefined;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||||
const publicKey = (account as { publicKey?: string }).publicKey;
|
const publicKey = (account as { publicKey?: string }).publicKey;
|
||||||
@ -120,13 +121,12 @@ export function renderNostrCard(params: {
|
|||||||
<div style="font-weight: 500;">Profile</div>
|
<div style="font-weight: 500;">Profile</div>
|
||||||
${summaryConfigured
|
${summaryConfigured
|
||||||
? html`
|
? html`
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn-sm"
|
size="sm"
|
||||||
@click=${onEditProfile}
|
@click=${onEditProfile}
|
||||||
style="font-size: 12px; padding: 4px 8px;"
|
|
||||||
>
|
>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</button>
|
</ui-button>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
@ -167,50 +167,69 @@ export function renderNostrCard(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Nostr</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
|
<div class="card-title">Nostr</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
${hasMultipleAccounts
|
${hasMultipleAccounts
|
||||||
? html`
|
? html`
|
||||||
<div class="account-card-list">
|
<div class="account-card-list">
|
||||||
${nostrAccounts.map((account) => renderAccountCard(account))}
|
${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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
`
|
||||||
<span class="label">Running</span>
|
: html`
|
||||||
<span>${summaryRunning ? "Yes" : "No"}</span>
|
<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>
|
||||||
<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
|
${renderProfileSection()}
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
${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;">
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -11,55 +11,75 @@ export function renderSignalCard(params: {
|
|||||||
accountCountLabel: unknown;
|
accountCountLabel: unknown;
|
||||||
}) {
|
}) {
|
||||||
const { props, signal, accountCountLabel } = params;
|
const { props, signal, accountCountLabel } = params;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Signal</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">signal-cli status and channel configuration.</div>
|
<div class="card-title">Signal</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">signal-cli status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${signal?.configured ? "Yes" : "No"}</span>
|
<span>${signal?.configured ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${signal?.running ? "Yes" : "No"}</span>
|
<span>${signal?.running ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Base URL</span>
|
<span class="label">Base URL</span>
|
||||||
<span>${signal?.baseUrl ?? "n/a"}</span>
|
<span>${signal?.baseUrl ?? "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${signal?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${signal?.lastError
|
||||||
${signal.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${signal.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${signal?.probe
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe ${signal.probe.ok ? "ok" : "failed"} ·
|
Probe
|
||||||
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
|
</ui-button>
|
||||||
</div>`
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
: nothing}
|
<ui-button
|
||||||
|
variant="primary"
|
||||||
${renderChannelConfigSection({ channelId: "signal", props })}
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
|
@click=${() => props.onConfigSave()}
|
||||||
<div class="row" style="margin-top: 12px;">
|
>
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
Probe
|
</ui-button>
|
||||||
</button>
|
<ui-button
|
||||||
|
?disabled=${configDisabled}
|
||||||
|
@click=${() => props.onConfigReload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -11,51 +11,71 @@ export function renderSlackCard(params: {
|
|||||||
accountCountLabel: unknown;
|
accountCountLabel: unknown;
|
||||||
}) {
|
}) {
|
||||||
const { props, slack, accountCountLabel } = params;
|
const { props, slack, accountCountLabel } = params;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Slack</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Socket mode status and channel configuration.</div>
|
<div class="card-title">Slack</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Socket mode status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${slack?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${slack?.lastError
|
||||||
${slack.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${slack.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${slack?.probe
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe ${slack.probe.ok ? "ok" : "failed"} ·
|
Probe
|
||||||
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
|
</ui-button>
|
||||||
</div>`
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
: nothing}
|
<ui-button
|
||||||
|
variant="primary"
|
||||||
${renderChannelConfigSection({ channelId: "slack", props })}
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
|
@click=${() => props.onConfigSave()}
|
||||||
<div class="row" style="margin-top: 12px;">
|
>
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
Probe
|
</ui-button>
|
||||||
</button>
|
<ui-button
|
||||||
|
?disabled=${configDisabled}
|
||||||
|
@click=${() => props.onConfigReload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function renderTelegramCard(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
||||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||||
|
const configDisabled = props.configSaving || props.configSchemaLoading;
|
||||||
|
|
||||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||||
@ -52,61 +53,80 @@ export function renderTelegramCard(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">Telegram</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Bot status and channel configuration.</div>
|
<div class="card-title">Telegram</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Bot status and channel configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
${hasMultipleAccounts
|
${hasMultipleAccounts
|
||||||
? html`
|
? html`
|
||||||
<div class="account-card-list">
|
<div class="account-card-list">
|
||||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
${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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
`
|
||||||
<span class="label">Running</span>
|
: html`
|
||||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
<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>
|
||||||
<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
|
${telegram?.probe
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
? html`<div class="callout" style="margin-top: 12px;">
|
||||||
${telegram.lastError}
|
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||||
</div>`
|
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${telegram?.probe
|
${renderChannelConfigSection({ channelId: "telegram", props })}
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
</div>
|
||||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
|
||||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
|
||||||
</div>`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
${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;">
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
Probe
|
Probe
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
|
|||||||
import type { ChannelsProps } from "./channels.types";
|
import type { ChannelsProps } from "./channels.types";
|
||||||
import { renderChannelConfigSection } from "./channels.config";
|
import { renderChannelConfigSection } from "./channels.config";
|
||||||
import { formatDuration } from "./channels.shared";
|
import { formatDuration } from "./channels.shared";
|
||||||
|
import "../components/dropdown-menu";
|
||||||
|
|
||||||
export function renderWhatsAppCard(params: {
|
export function renderWhatsAppCard(params: {
|
||||||
props: ChannelsProps;
|
props: ChannelsProps;
|
||||||
@ -13,106 +14,132 @@ export function renderWhatsAppCard(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { props, whatsapp, accountCountLabel } = 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`
|
return html`
|
||||||
<div class="card">
|
<div class="card card--channel">
|
||||||
<div class="card-title">WhatsApp</div>
|
<div class="card-content">
|
||||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
<div class="card-title">WhatsApp</div>
|
||||||
${accountCountLabel}
|
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Linked</span>
|
<span class="label">Linked</span>
|
||||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Connected</span>
|
<span class="label">Connected</span>
|
||||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last connect</span>
|
<span class="label">Last connect</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.lastConnectedAt
|
${whatsapp?.lastConnectedAt
|
||||||
? formatAgo(whatsapp.lastConnectedAt)
|
? formatAgo(whatsapp.lastConnectedAt)
|
||||||
: "n/a"}
|
: "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last message</span>
|
<span class="label">Last message</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Auth age</span>
|
<span class="label">Auth age</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.authAgeMs != null
|
${whatsapp?.authAgeMs != null
|
||||||
? formatDuration(whatsapp.authAgeMs)
|
? formatDuration(whatsapp.authAgeMs)
|
||||||
: "n/a"}
|
: "n/a"}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
${whatsapp?.lastError
|
<div class="card-footer">
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
${whatsapp?.lastError
|
||||||
${whatsapp.lastError}
|
? html`<div class="callout danger" style="margin-bottom: 12px;">
|
||||||
</div>`
|
${whatsapp.lastError}
|
||||||
: nothing}
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${props.whatsappMessage
|
<div class="row" style="gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
<ui-button
|
||||||
${props.whatsappMessage}
|
variant="primary"
|
||||||
</div>`
|
?disabled=${props.whatsappBusy}
|
||||||
: nothing}
|
@click=${() => props.onWhatsAppStart(false)}
|
||||||
|
>
|
||||||
${props.whatsappQrDataUrl
|
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||||
? html`<div class="qr-wrap">
|
</ui-button>
|
||||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
<ui-button @click=${() => props.onRefresh(true)}>
|
||||||
</div>`
|
Refresh
|
||||||
: nothing}
|
</ui-button>
|
||||||
|
<ui-dropdown-menu
|
||||||
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
.items=${menuItems}
|
||||||
<button
|
?disabled=${props.whatsappBusy}
|
||||||
class="btn primary"
|
@select=${handleMenuSelect}
|
||||||
?disabled=${props.whatsappBusy}
|
></ui-dropdown-menu>
|
||||||
@click=${() => props.onWhatsAppStart(false)}
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
>
|
<ui-button
|
||||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
variant="primary"
|
||||||
</button>
|
?disabled=${configDisabled || !props.configFormDirty}
|
||||||
<button
|
@click=${() => props.onConfigSave()}
|
||||||
class="btn"
|
>
|
||||||
?disabled=${props.whatsappBusy}
|
${props.configSaving ? "Saving…" : "Save Config"}
|
||||||
@click=${() => props.onWhatsAppStart(true)}
|
</ui-button>
|
||||||
>
|
<ui-button
|
||||||
Relink
|
?disabled=${configDisabled}
|
||||||
</button>
|
@click=${() => props.onConfigReload()}
|
||||||
<button
|
>
|
||||||
class="btn"
|
Reload
|
||||||
?disabled=${props.whatsappBusy}
|
</ui-button>
|
||||||
@click=${() => props.onWhatsAppWait()}
|
</div>
|
||||||
>
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
${renderChannelConfigSection({ channelId: "whatsapp", props })}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from "../chat/grouped-render";
|
} from "../chat/grouped-render";
|
||||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||||
import "../components/resizable-divider";
|
import "../components/resizable-divider";
|
||||||
|
import "../components/button";
|
||||||
|
|
||||||
export type CompactionIndicatorStatus = {
|
export type CompactionIndicatorStatus = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|||||||
@ -27,13 +27,18 @@ function jsonValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SVG Icons as template literals
|
// SVG Icons as template literals
|
||||||
const icons = {
|
import "../components";
|
||||||
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>`,
|
// Helper to wrap generic patch with component events
|
||||||
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>`,
|
function handlePatch(
|
||||||
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>`,
|
path: Array<string | number>,
|
||||||
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>`,
|
onPatch: (path: Array<string | number>, value: unknown) => void
|
||||||
};
|
) {
|
||||||
|
return (e: CustomEvent) => {
|
||||||
|
const { value, checked } = e.detail;
|
||||||
|
onPatch(path, checked ?? value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function renderNode(params: {
|
export function renderNode(params: {
|
||||||
schema: JsonSchema;
|
schema: JsonSchema;
|
||||||
@ -83,22 +88,21 @@ export function renderNode(params: {
|
|||||||
if (allLiterals && literals.length > 0 && literals.length <= 5) {
|
if (allLiterals && literals.length > 0 && literals.length <= 5) {
|
||||||
// Use segmented control for small sets
|
// Use segmented control for small sets
|
||||||
const resolvedValue = value ?? schema.default;
|
const resolvedValue = value ?? schema.default;
|
||||||
|
const options = literals.map((lit) => ({ label: String(lit), value: String(lit) }));
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
<div class="cfg-segmented">
|
<ui-segmented-control
|
||||||
${literals.map((lit, idx) => html`
|
.options=${options}
|
||||||
<button
|
.value=${String(resolvedValue)}
|
||||||
type="button"
|
@change=${(e: CustomEvent) => {
|
||||||
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
|
// Find original literal type
|
||||||
?disabled=${disabled}
|
const selected = literals.find(l => String(l) === e.detail.value);
|
||||||
@click=${() => onPatch(path, lit)}
|
onPatch(path, selected);
|
||||||
>
|
}}
|
||||||
${String(lit)}
|
></ui-segmented-control>
|
||||||
</button>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -142,22 +146,18 @@ export function renderNode(params: {
|
|||||||
const options = schema.enum;
|
const options = schema.enum;
|
||||||
if (options.length <= 5) {
|
if (options.length <= 5) {
|
||||||
const resolvedValue = value ?? schema.default;
|
const resolvedValue = value ?? schema.default;
|
||||||
|
const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) }));
|
||||||
return html`
|
return html`
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
<div class="cfg-segmented">
|
<ui-segmented-control
|
||||||
${options.map((opt) => html`
|
.options=${enumOptions}
|
||||||
<button
|
.value=${String(resolvedValue)}
|
||||||
type="button"
|
@change=${(e: CustomEvent) => {
|
||||||
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}"
|
onPatch(path, e.detail.value);
|
||||||
?disabled=${disabled}
|
}}
|
||||||
@click=${() => onPatch(path, opt)}
|
></ui-segmented-control>
|
||||||
>
|
|
||||||
${String(opt)}
|
|
||||||
</button>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -178,21 +178,17 @@ export function renderNode(params: {
|
|||||||
if (type === "boolean") {
|
if (type === "boolean") {
|
||||||
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
|
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
|
||||||
return html`
|
return html`
|
||||||
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
|
<div class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
|
||||||
<div class="cfg-toggle-row__content">
|
<div class="cfg-toggle-row__content">
|
||||||
<span class="cfg-toggle-row__label">${label}</span>
|
<span class="cfg-toggle-row__label">${label}</span>
|
||||||
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
|
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-toggle">
|
<ui-switch
|
||||||
<input
|
.checked=${displayValue}
|
||||||
type="checkbox"
|
?disabled=${disabled}
|
||||||
.checked=${displayValue}
|
@change=${handlePatch(path, onPatch)}
|
||||||
?disabled=${disabled}
|
></ui-switch>
|
||||||
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)}
|
</div>
|
||||||
/>
|
|
||||||
<span class="cfg-toggle__track"></span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,34 +237,34 @@ function renderTextInput(params: {
|
|||||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
<div class="cfg-input-wrap">
|
<div class="cfg-input-wrap">
|
||||||
<input
|
<ui-input
|
||||||
type=${isSensitive ? "password" : inputType}
|
.type=${isSensitive ? "password" : inputType}
|
||||||
class="cfg-input"
|
.placeholder=${placeholder}
|
||||||
placeholder=${placeholder}
|
|
||||||
.value=${displayValue == null ? "" : String(displayValue)}
|
.value=${displayValue == null ? "" : String(displayValue)}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: CustomEvent) => {
|
||||||
const raw = (e.target as HTMLInputElement).value;
|
const raw = e.detail.value;
|
||||||
if (inputType === "number") {
|
if (inputType === "number") {
|
||||||
if (raw.trim() === "") {
|
if (raw.trim() === "") {
|
||||||
onPatch(path, undefined);
|
onPatch(path, undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = Number(raw);
|
const parsed = Number(raw);
|
||||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onPatch(path, raw);
|
onPatch(path, raw);
|
||||||
}}
|
}}
|
||||||
/>
|
></ui-input>
|
||||||
${schema.default !== undefined ? html`
|
${schema.default !== undefined ? html`
|
||||||
<button
|
<ui-button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
class="cfg-input__reset"
|
class="cfg-input__reset"
|
||||||
title="Reset to default"
|
title="Reset to default"
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@click=${() => onPatch(path, schema.default)}
|
@click=${() => onPatch(path, schema.default)}
|
||||||
>↺</button>
|
>↺</ui-button>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -297,29 +293,16 @@ function renderNumberInput(params: {
|
|||||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
<div class="cfg-number">
|
<div class="cfg-number">
|
||||||
<button
|
<ui-input
|
||||||
type="button"
|
|
||||||
class="cfg-number__btn"
|
|
||||||
?disabled=${disabled}
|
|
||||||
@click=${() => onPatch(path, numValue - 1)}
|
|
||||||
>−</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
class="cfg-number__input"
|
|
||||||
.value=${displayValue == null ? "" : String(displayValue)}
|
.value=${displayValue == null ? "" : String(displayValue)}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: CustomEvent) => {
|
||||||
const raw = (e.target as HTMLInputElement).value;
|
const raw = e.detail.value;
|
||||||
const parsed = raw === "" ? undefined : Number(raw);
|
const parsed = raw === "" ? undefined : Number(raw);
|
||||||
onPatch(path, parsed);
|
onPatch(path, parsed);
|
||||||
}}
|
}}
|
||||||
/>
|
></ui-input>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cfg-number__btn"
|
|
||||||
?disabled=${disabled}
|
|
||||||
@click=${() => onPatch(path, numValue + 1)}
|
|
||||||
>+</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -350,20 +333,18 @@ function renderSelect(params: {
|
|||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
<select
|
<ui-select
|
||||||
class="cfg-select"
|
.options=${[
|
||||||
|
{ label: "Select...", value: unset },
|
||||||
|
...options.map((opt, idx) => ({ label: String(opt), value: String(idx) }))
|
||||||
|
]}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
|
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: CustomEvent) => {
|
||||||
const val = (e.target as HTMLSelectElement).value;
|
const val = e.detail.value;
|
||||||
onPatch(path, val === unset ? undefined : options[Number(val)]);
|
onPatch(path, val === unset ? undefined : options[Number(val)]);
|
||||||
}}
|
}}
|
||||||
>
|
></ui-select>
|
||||||
<option value=${unset}>Select...</option>
|
|
||||||
${options.map((opt, idx) => html`
|
|
||||||
<option value=${String(idx)}>${String(opt)}</option>
|
|
||||||
`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -408,26 +389,26 @@ function renderObject(params: {
|
|||||||
return html`
|
return html`
|
||||||
<div class="cfg-fields">
|
<div class="cfg-fields">
|
||||||
${sorted.map(([propKey, node]) =>
|
${sorted.map(([propKey, node]) =>
|
||||||
renderNode({
|
renderNode({
|
||||||
schema: node,
|
schema: node,
|
||||||
value: obj[propKey],
|
value: obj[propKey],
|
||||||
path: [...path, propKey],
|
path: [...path, propKey],
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
onPatch,
|
onPatch,
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
${allowExtra ? renderMapField({
|
${allowExtra ? renderMapField({
|
||||||
schema: additional as JsonSchema,
|
schema: additional as JsonSchema,
|
||||||
value: obj,
|
value: obj,
|
||||||
path,
|
path,
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
reservedKeys: reserved,
|
reservedKeys: reserved,
|
||||||
onPatch,
|
onPatch,
|
||||||
}) : nothing}
|
}) : nothing}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -437,31 +418,31 @@ function renderObject(params: {
|
|||||||
<details class="cfg-object" open>
|
<details class="cfg-object" open>
|
||||||
<summary class="cfg-object__header">
|
<summary class="cfg-object__header">
|
||||||
<span class="cfg-object__title">${label}</span>
|
<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>
|
</summary>
|
||||||
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
|
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
|
||||||
<div class="cfg-object__content">
|
<div class="cfg-object__content">
|
||||||
${sorted.map(([propKey, node]) =>
|
${sorted.map(([propKey, node]) =>
|
||||||
renderNode({
|
renderNode({
|
||||||
schema: node,
|
schema: node,
|
||||||
value: obj[propKey],
|
value: obj[propKey],
|
||||||
path: [...path, propKey],
|
path: [...path, propKey],
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
onPatch,
|
onPatch,
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
${allowExtra ? renderMapField({
|
${allowExtra ? renderMapField({
|
||||||
schema: additional as JsonSchema,
|
schema: additional as JsonSchema,
|
||||||
value: obj,
|
value: obj,
|
||||||
path,
|
path,
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
reservedKeys: reserved,
|
reservedKeys: reserved,
|
||||||
onPatch,
|
onPatch,
|
||||||
}) : nothing}
|
}) : nothing}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
@ -499,19 +480,17 @@ function renderArray(params: {
|
|||||||
<div class="cfg-array">
|
<div class="cfg-array">
|
||||||
<div class="cfg-array__header">
|
<div class="cfg-array__header">
|
||||||
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
|
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
|
||||||
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
|
<ui-badge variant="secondary">${arr.length} item${arr.length !== 1 ? 's' : ''}</ui-badge>
|
||||||
<button
|
<ui-button
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="cfg-array__add"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = [...arr, defaultValue(itemsSchema)];
|
const next = [...arr, defaultValue(itemsSchema)];
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
>
|
>Add</ui-button>
|
||||||
<span class="cfg-array__add-icon">${icons.plus}</span>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
|
${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">
|
||||||
<div class="cfg-array__item-header">
|
<div class="cfg-array__item-header">
|
||||||
<span class="cfg-array__item-index">#${idx + 1}</span>
|
<span class="cfg-array__item-index">#${idx + 1}</span>
|
||||||
<button
|
<ui-button
|
||||||
type="button"
|
variant="ghost"
|
||||||
class="cfg-array__item-remove"
|
size="icon"
|
||||||
|
icon="trash"
|
||||||
title="Remove item"
|
title="Remove item"
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = [...arr];
|
const next = [...arr];
|
||||||
next.splice(idx, 1);
|
next.splice(idx, 1);
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
>
|
></ui-button>
|
||||||
${icons.trash}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-array__item-content">
|
<div class="cfg-array__item-content">
|
||||||
${renderNode({
|
${renderNode({
|
||||||
schema: itemsSchema,
|
schema: itemsSchema,
|
||||||
value: item,
|
value: item,
|
||||||
path: [...path, idx],
|
path: [...path, idx],
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
onPatch,
|
onPatch,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`)}
|
||||||
@ -577,25 +555,23 @@ function renderMapField(params: {
|
|||||||
<div class="cfg-map">
|
<div class="cfg-map">
|
||||||
<div class="cfg-map__header">
|
<div class="cfg-map__header">
|
||||||
<span class="cfg-map__label">Custom entries</span>
|
<span class="cfg-map__label">Custom entries</span>
|
||||||
<button
|
<ui-button
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="cfg-map__add"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...(value ?? {}) };
|
const next = { ...(value ?? {}) };
|
||||||
let index = 1;
|
let index = 1;
|
||||||
let key = `custom-${index}`;
|
let key = `custom-${index}`;
|
||||||
while (key in next) {
|
while (key in next) {
|
||||||
index += 1;
|
index += 1;
|
||||||
key = `custom-${index}`;
|
key = `custom-${index}`;
|
||||||
}
|
}
|
||||||
next[key] = anySchema ? {} : defaultValue(schema);
|
next[key] = anySchema ? {} : defaultValue(schema);
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
>
|
>Add Entry</ui-button>
|
||||||
<span class="cfg-map__add-icon">${icons.plus}</span>
|
|
||||||
Add Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${entries.length === 0 ? html`
|
${entries.length === 0 ? html`
|
||||||
@ -603,79 +579,75 @@ function renderMapField(params: {
|
|||||||
` : html`
|
` : html`
|
||||||
<div class="cfg-map__items">
|
<div class="cfg-map__items">
|
||||||
${entries.map(([key, entryValue]) => {
|
${entries.map(([key, entryValue]) => {
|
||||||
const valuePath = [...path, key];
|
const valuePath = [...path, key];
|
||||||
const fallback = jsonValue(entryValue);
|
const fallback = jsonValue(entryValue);
|
||||||
return html`
|
return html`
|
||||||
<div class="cfg-map__item">
|
<div class="cfg-map__item">
|
||||||
<div class="cfg-map__item-key">
|
<div class="cfg-map__item-key">
|
||||||
<input
|
<ui-input
|
||||||
type="text"
|
|
||||||
class="cfg-input cfg-input--sm"
|
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
.value=${key}
|
.value=${key}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: CustomEvent) => {
|
||||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
const nextKey = e.detail.value.trim();
|
||||||
if (!nextKey || nextKey === key) return;
|
if (!nextKey || nextKey === key) return;
|
||||||
const next = { ...(value ?? {}) };
|
const next = { ...(value ?? {}) };
|
||||||
if (nextKey in next) return;
|
if (nextKey in next) return;
|
||||||
next[nextKey] = next[key];
|
next[nextKey] = next[key];
|
||||||
delete next[key];
|
delete next[key];
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
/>
|
></ui-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-map__item-value">
|
<div class="cfg-map__item-value">
|
||||||
${anySchema
|
${anySchema
|
||||||
? html`
|
? html`
|
||||||
<textarea
|
<ui-textarea
|
||||||
class="cfg-textarea cfg-textarea--sm"
|
|
||||||
placeholder="JSON value"
|
placeholder="JSON value"
|
||||||
rows="2"
|
.rows=${2}
|
||||||
.value=${fallback}
|
.value=${fallback}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: CustomEvent) => {
|
||||||
const target = e.target as HTMLTextAreaElement;
|
const raw = e.detail.value.trim();
|
||||||
const raw = target.value.trim();
|
if (!raw) {
|
||||||
if (!raw) {
|
onPatch(valuePath, undefined);
|
||||||
onPatch(valuePath, undefined);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
try {
|
||||||
try {
|
onPatch(valuePath, JSON.parse(raw));
|
||||||
onPatch(valuePath, JSON.parse(raw));
|
} catch {
|
||||||
} catch {
|
// target.value = fallback; // ui-textarea handles its own value
|
||||||
target.value = fallback;
|
}
|
||||||
}
|
}}
|
||||||
}}
|
></ui-textarea>
|
||||||
></textarea>
|
|
||||||
`
|
`
|
||||||
: renderNode({
|
: renderNode({
|
||||||
schema,
|
schema,
|
||||||
value: entryValue,
|
value: entryValue,
|
||||||
path: valuePath,
|
path: valuePath,
|
||||||
hints,
|
hints,
|
||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
onPatch,
|
onPatch,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ui-button
|
||||||
type="button"
|
type="button"
|
||||||
class="cfg-map__item-remove"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
icon="trash"
|
||||||
title="Remove entry"
|
title="Remove entry"
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...(value ?? {}) };
|
const next = { ...(value ?? {}) };
|
||||||
delete next[key];
|
delete next[key];
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
>
|
></ui-button>
|
||||||
${icons.trash}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,10 @@ import {
|
|||||||
schemaType,
|
schemaType,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
} from "./config-form.shared";
|
} from "./config-form.shared";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/badge";
|
||||||
|
import "../components/input";
|
||||||
|
import "../components/textarea";
|
||||||
|
|
||||||
export type ConfigProps = {
|
export type ConfigProps = {
|
||||||
raw: string;
|
raw: string;
|
||||||
@ -261,17 +265,12 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="config-search">
|
<div class="config-search">
|
||||||
<svg class="config-search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<ui-input
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<path d="M21 21l-4.35-4.35"></path>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="config-search__input"
|
|
||||||
placeholder="Search settings..."
|
placeholder="Search settings..."
|
||||||
.value=${props.searchQuery}
|
.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`
|
${props.searchQuery ? html`
|
||||||
<button
|
<button
|
||||||
class="config-search__clear"
|
class="config-search__clear"
|
||||||
@ -305,7 +304,6 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
<div class="config-mode-toggle">
|
<div class="config-mode-toggle">
|
||||||
<button
|
<button
|
||||||
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
||||||
?disabled=${props.schemaLoading || !props.schema}
|
|
||||||
@click=${() => props.onFormModeChange("form")}
|
@click=${() => props.onFormModeChange("form")}
|
||||||
>
|
>
|
||||||
Form
|
Form
|
||||||
@ -332,30 +330,31 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="config-actions__right">
|
<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"}
|
${props.loading ? "Loading…" : "Reload"}
|
||||||
</button>
|
</ui-button>
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm primary"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
?disabled=${!canSave}
|
?disabled=${!canSave}
|
||||||
@click=${props.onSave}
|
@click=${props.onSave}
|
||||||
>
|
>
|
||||||
${props.saving ? "Saving…" : "Save"}
|
${props.saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</ui-button>
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm"
|
size="sm"
|
||||||
?disabled=${!canApply}
|
?disabled=${!canApply}
|
||||||
@click=${props.onApply}
|
@click=${props.onApply}
|
||||||
>
|
>
|
||||||
${props.applying ? "Applying…" : "Apply"}
|
${props.applying ? "Applying…" : "Apply"}
|
||||||
</button>
|
</ui-button>
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm"
|
size="sm"
|
||||||
?disabled=${!canUpdate}
|
?disabled=${!canUpdate}
|
||||||
@click=${props.onUpdate}
|
@click=${props.onUpdate}
|
||||||
>
|
>
|
||||||
${props.updating ? "Updating…" : "Update"}
|
${props.updating ? "Updating…" : "Update"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -451,14 +450,15 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<label class="field config-raw-field">
|
<div class="config-raw-wrap">
|
||||||
<span>Raw JSON5</span>
|
<ui-textarea
|
||||||
<textarea
|
label="Raw JSON5"
|
||||||
.value=${props.raw}
|
.value=${props.raw}
|
||||||
@input=${(e: Event) =>
|
.rows=${25}
|
||||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
@input=${(e: CustomEvent) =>
|
||||||
></textarea>
|
props.onRawChange(e.detail.value)}
|
||||||
</label>
|
></ui-textarea>
|
||||||
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -64,10 +64,10 @@ export function renderCron(props: CronProps) {
|
|||||||
<div class="stat-label">Enabled</div>
|
<div class="stat-label">Enabled</div>
|
||||||
<div class="stat-value">
|
<div class="stat-value">
|
||||||
${props.status
|
${props.status
|
||||||
? props.status.enabled
|
? props.status.enabled
|
||||||
? "Yes"
|
? "Yes"
|
||||||
: "No"
|
: "No"
|
||||||
: "n/a"}
|
: "n/a"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@ -96,7 +96,7 @@ export function renderCron(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${props.form.name}
|
.value=${props.form.name}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -104,7 +104,7 @@ export function renderCron(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${props.form.description}
|
.value=${props.form.description}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -112,27 +112,26 @@ export function renderCron(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${props.form.agentId}
|
.value=${props.form.agentId}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
|
||||||
placeholder="default"
|
placeholder="default"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field checkbox">
|
<div style="display: flex; align-items: flex-end; height: 100%; padding-bottom: 10px;">
|
||||||
<span>Enabled</span>
|
<ui-checkbox
|
||||||
<input
|
label="Enabled"
|
||||||
type="checkbox"
|
|
||||||
.checked=${props.form.enabled}
|
.checked=${props.form.enabled}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: CustomEvent) =>
|
||||||
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
|
props.onFormChange({ enabled: e.detail.checked })}
|
||||||
/>
|
></ui-checkbox>
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Schedule</span>
|
<span>Schedule</span>
|
||||||
<select
|
<select
|
||||||
.value=${props.form.scheduleKind}
|
.value=${props.form.scheduleKind}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
|
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="every">Every</option>
|
<option value="every">Every</option>
|
||||||
<option value="at">At</option>
|
<option value="at">At</option>
|
||||||
@ -147,9 +146,9 @@ export function renderCron(props: CronProps) {
|
|||||||
<select
|
<select
|
||||||
.value=${props.form.sessionTarget}
|
.value=${props.form.sessionTarget}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
|
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="main">Main</option>
|
<option value="main">Main</option>
|
||||||
<option value="isolated">Isolated</option>
|
<option value="isolated">Isolated</option>
|
||||||
@ -160,9 +159,9 @@ export function renderCron(props: CronProps) {
|
|||||||
<select
|
<select
|
||||||
.value=${props.form.wakeMode}
|
.value=${props.form.wakeMode}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
|
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="next-heartbeat">Next heartbeat</option>
|
<option value="next-heartbeat">Next heartbeat</option>
|
||||||
<option value="now">Now</option>
|
<option value="now">Now</option>
|
||||||
@ -173,9 +172,9 @@ export function renderCron(props: CronProps) {
|
|||||||
<select
|
<select
|
||||||
.value=${props.form.payloadKind}
|
.value=${props.form.payloadKind}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
|
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="systemEvent">System event</option>
|
<option value="systemEvent">System event</option>
|
||||||
<option value="agentTurn">Agent turn</option>
|
<option value="agentTurn">Agent turn</option>
|
||||||
@ -187,14 +186,14 @@ export function renderCron(props: CronProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
.value=${props.form.payloadText}
|
.value=${props.form.payloadText}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
payloadText: (e.target as HTMLTextAreaElement).value,
|
payloadText: (e.target as HTMLTextAreaElement).value,
|
||||||
})}
|
})}
|
||||||
rows="4"
|
rows="4"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
${props.form.payloadKind === "agentTurn"
|
${props.form.payloadKind === "agentTurn"
|
||||||
? html`
|
? html`
|
||||||
<div class="form-grid" style="margin-top: 12px;">
|
<div class="form-grid" style="margin-top: 12px;">
|
||||||
<label class="field checkbox">
|
<label class="field checkbox">
|
||||||
<span>Deliver</span>
|
<span>Deliver</span>
|
||||||
@ -202,26 +201,26 @@ export function renderCron(props: CronProps) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
.checked=${props.form.deliver}
|
.checked=${props.form.deliver}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
deliver: (e.target as HTMLInputElement).checked,
|
deliver: (e.target as HTMLInputElement).checked,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Channel</span>
|
<span>Channel</span>
|
||||||
<select
|
<select
|
||||||
.value=${props.form.channel || "last"}
|
.value=${props.form.channel || "last"}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
|
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
${channelOptions.map(
|
${channelOptions.map(
|
||||||
(channel) =>
|
(channel) =>
|
||||||
html`<option value=${channel}>
|
html`<option value=${channel}>
|
||||||
${resolveChannelLabel(props, channel)}
|
${resolveChannelLabel(props, channel)}
|
||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -229,7 +228,7 @@ export function renderCron(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${props.form.to}
|
.value=${props.form.to}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
||||||
placeholder="+1555… or chat id"
|
placeholder="+1555… or chat id"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -238,28 +237,28 @@ export function renderCron(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${props.form.timeoutSeconds}
|
.value=${props.form.timeoutSeconds}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
timeoutSeconds: (e.target as HTMLInputElement).value,
|
timeoutSeconds: (e.target as HTMLInputElement).value,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
${props.form.sessionTarget === "isolated"
|
${props.form.sessionTarget === "isolated"
|
||||||
? html`
|
? html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Post to main prefix</span>
|
<span>Post to main prefix</span>
|
||||||
<input
|
<input
|
||||||
.value=${props.form.postToMainPrefix}
|
.value=${props.form.postToMainPrefix}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
postToMainPrefix: (e.target as HTMLInputElement).value,
|
postToMainPrefix: (e.target as HTMLInputElement).value,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="row" style="margin-top: 14px;">
|
<div class="row" style="margin-top: 14px;">
|
||||||
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
||||||
${props.busy ? "Saving…" : "Add job"}
|
${props.busy ? "Saving…" : "Add job"}
|
||||||
@ -272,8 +271,8 @@ export function renderCron(props: CronProps) {
|
|||||||
<div class="card-title">Jobs</div>
|
<div class="card-title">Jobs</div>
|
||||||
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
|
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
|
||||||
${props.jobs.length === 0
|
${props.jobs.length === 0
|
||||||
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
|
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
|
||||||
: html`
|
: html`
|
||||||
<div class="list" style="margin-top: 12px;">
|
<div class="list" style="margin-top: 12px;">
|
||||||
${props.jobs.map((job) => renderJob(job, props))}
|
${props.jobs.map((job) => renderJob(job, props))}
|
||||||
</div>
|
</div>
|
||||||
@ -284,14 +283,14 @@ export function renderCron(props: CronProps) {
|
|||||||
<div class="card-title">Run history</div>
|
<div class="card-title">Run history</div>
|
||||||
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
|
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
|
||||||
${props.runsJobId == null
|
${props.runsJobId == null
|
||||||
? html`
|
? html`
|
||||||
<div class="muted" style="margin-top: 12px;">
|
<div class="muted" style="margin-top: 12px;">
|
||||||
Select a job to inspect run history.
|
Select a job to inspect run history.
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: props.runs.length === 0
|
: props.runs.length === 0
|
||||||
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
|
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
|
||||||
: html`
|
: html`
|
||||||
<div class="list" style="margin-top: 12px;">
|
<div class="list" style="margin-top: 12px;">
|
||||||
${props.runs.map((entry) => renderRun(entry))}
|
${props.runs.map((entry) => renderRun(entry))}
|
||||||
</div>
|
</div>
|
||||||
@ -310,9 +309,9 @@ function renderScheduleFields(props: CronProps) {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
.value=${form.scheduleAt}
|
.value=${form.scheduleAt}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
scheduleAt: (e.target as HTMLInputElement).value,
|
scheduleAt: (e.target as HTMLInputElement).value,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
`;
|
`;
|
||||||
@ -325,9 +324,9 @@ function renderScheduleFields(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${form.everyAmount}
|
.value=${form.everyAmount}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
everyAmount: (e.target as HTMLInputElement).value,
|
everyAmount: (e.target as HTMLInputElement).value,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -335,9 +334,9 @@ function renderScheduleFields(props: CronProps) {
|
|||||||
<select
|
<select
|
||||||
.value=${form.everyUnit}
|
.value=${form.everyUnit}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
|
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="minutes">Minutes</option>
|
<option value="minutes">Minutes</option>
|
||||||
<option value="hours">Hours</option>
|
<option value="hours">Hours</option>
|
||||||
@ -354,7 +353,7 @@ function renderScheduleFields(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${form.cronExpr}
|
.value=${form.cronExpr}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -362,7 +361,7 @@ function renderScheduleFields(props: CronProps) {
|
|||||||
<input
|
<input
|
||||||
.value=${form.cronTz}
|
.value=${form.cronTz}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
|
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -392,9 +391,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
class="btn"
|
class="btn"
|
||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
props.onToggle(job, !job.enabled);
|
props.onToggle(job, !job.enabled);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${job.enabled ? "Disable" : "Enable"}
|
${job.enabled ? "Disable" : "Enable"}
|
||||||
</button>
|
</button>
|
||||||
@ -402,9 +401,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
class="btn"
|
class="btn"
|
||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
props.onRun(job);
|
props.onRun(job);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Run
|
Run
|
||||||
</button>
|
</button>
|
||||||
@ -412,9 +411,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
class="btn"
|
class="btn"
|
||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
props.onLoadRuns(job.id);
|
props.onLoadRuns(job.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Runs
|
Runs
|
||||||
</button>
|
</button>
|
||||||
@ -422,9 +421,9 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
class="btn danger"
|
class="btn danger"
|
||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
props.onRemove(job);
|
props.onRemove(job);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { html, nothing } from "lit";
|
|||||||
|
|
||||||
import { formatEventPayload } from "../presenter";
|
import { formatEventPayload } from "../presenter";
|
||||||
import type { EventLogEntry } from "../app-events";
|
import type { EventLogEntry } from "../app-events";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/input";
|
||||||
|
|
||||||
export type DebugProps = {
|
export type DebugProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -45,9 +47,9 @@ export function renderDebug(props: DebugProps) {
|
|||||||
<div class="card-title">Snapshots</div>
|
<div class="card-title">Snapshots</div>
|
||||||
<div class="card-sub">Status, health, and heartbeat data.</div>
|
<div class="card-sub">Status, health, and heartbeat data.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||||
${props.loading ? "Refreshing…" : "Refresh"}
|
${props.loading ? "Refreshing…" : "Refresh"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack" style="margin-top: 12px;">
|
<div class="stack" style="margin-top: 12px;">
|
||||||
<div>
|
<div>
|
||||||
@ -74,28 +76,26 @@ export function renderDebug(props: DebugProps) {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Manual RPC</div>
|
<div class="card-title">Manual RPC</div>
|
||||||
<div class="card-sub">Send a raw gateway method with JSON params.</div>
|
<div class="card-sub">Send a raw gateway method with JSON params.</div>
|
||||||
<div class="form-grid" style="margin-top: 16px;">
|
<div style="margin-top: 16px; display: grid; gap: 16px;">
|
||||||
<label class="field">
|
<ui-input
|
||||||
<span>Method</span>
|
label="Method"
|
||||||
<input
|
.value=${props.callMethod}
|
||||||
.value=${props.callMethod}
|
placeholder="system-presence"
|
||||||
@input=${(e: Event) =>
|
@input=${(e: CustomEvent) => props.onCallMethodChange(e.detail.value)}
|
||||||
props.onCallMethodChange((e.target as HTMLInputElement).value)}
|
></ui-input>
|
||||||
placeholder="system-presence"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Params (JSON)</span>
|
<span>Params (JSON)</span>
|
||||||
<textarea
|
<textarea
|
||||||
.value=${props.callParams}
|
.value=${props.callParams}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
|
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
|
||||||
|
placeholder="{}"
|
||||||
rows="6"
|
rows="6"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 12px;">
|
<div style="margin-top: 16px;">
|
||||||
<button class="btn primary" @click=${props.onCall}>Call</button>
|
<ui-button variant="primary" @click=${props.onCall}>Call</ui-button>
|
||||||
</div>
|
</div>
|
||||||
${props.callError
|
${props.callError
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
import type { LogEntry, LogLevel } from "../types";
|
import type { LogEntry, LogLevel } from "../types";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/input";
|
||||||
|
import "../components/checkbox";
|
||||||
|
|
||||||
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
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 class="card-sub">Gateway file logs (JSONL).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="gap: 8px;">
|
<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"}
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</ui-button>
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
|
||||||
?disabled=${filtered.length === 0}
|
?disabled=${filtered.length === 0}
|
||||||
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
|
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
|
||||||
>
|
>
|
||||||
Export ${exportLabel}
|
Export ${exportLabel}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 14px;">
|
<div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
|
||||||
<label class="field" style="min-width: 220px;">
|
<ui-input
|
||||||
<span>Filter</span>
|
label="Filter"
|
||||||
<input
|
.value=${props.filterText}
|
||||||
.value=${props.filterText}
|
@input=${(e: CustomEvent) => props.onFilterTextChange(e.detail.value)}
|
||||||
@input=${(e: Event) =>
|
placeholder="Search logs"
|
||||||
props.onFilterTextChange((e.target as HTMLInputElement).value)}
|
style="width: 240px;"
|
||||||
placeholder="Search logs"
|
></ui-input>
|
||||||
/>
|
<div style="margin-top: 19px;">
|
||||||
</label>
|
<ui-checkbox
|
||||||
<label class="field checkbox">
|
label="Auto-follow"
|
||||||
<span>Auto-follow</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
.checked=${props.autoFollow}
|
.checked=${props.autoFollow}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: CustomEvent) => props.onToggleAutoFollow(e.detail.checked)}
|
||||||
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)}
|
></ui-checkbox>
|
||||||
/>
|
</div>
|
||||||
</label>
|
|
||||||
</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(
|
${LEVELS.map(
|
||||||
(level) => html`
|
(level) => html`
|
||||||
<label class="chip log-chip ${level}">
|
<label class="log-level-filter log-level-filter--${level}" style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
.checked=${props.levelFilters[level]}
|
.checked=${props.levelFilters[level]}
|
||||||
@change=${(e: Event) =>
|
@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>
|
</label>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${props.file
|
${props.file
|
||||||
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
|
? html`<div class="muted" style="margin-top: 12px;">File: ${props.file}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${props.truncated
|
${props.truncated
|
||||||
? html`<div class="callout" style="margin-top: 10px;">
|
? html`<div class="callout" style="margin-top: 12px;">
|
||||||
Log output truncated; showing latest chunk.
|
Log output truncated; showing latest chunk.
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${props.error
|
${props.error
|
||||||
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
|
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||||
: nothing}
|
: 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
|
${filtered.length === 0
|
||||||
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
|
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
|
||||||
: filtered.map(
|
: filtered.map(
|
||||||
(entry) => html`
|
(entry) => html`
|
||||||
<div class="log-row">
|
<div class="log-row">
|
||||||
<div class="log-time mono">${formatTime(entry.time)}</div>
|
<div class="log-time mono">${formatTime(entry.time)}</div>
|
||||||
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</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 class="log-message mono">${entry.message ?? entry.raw}</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -12,6 +12,12 @@ import type {
|
|||||||
PairedDevice,
|
PairedDevice,
|
||||||
PendingDevice,
|
PendingDevice,
|
||||||
} from "../controllers/devices";
|
} from "../controllers/devices";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/badge";
|
||||||
|
import "../components/input";
|
||||||
|
import "../components/card";
|
||||||
|
import "../components/checkbox";
|
||||||
|
import "../components/select";
|
||||||
|
|
||||||
export type NodesProps = {
|
export type NodesProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -57,22 +63,24 @@ export function renderNodes(props: NodesProps) {
|
|||||||
${renderExecApprovals(approvalsState)}
|
${renderExecApprovals(approvalsState)}
|
||||||
${renderBindings(bindingState)}
|
${renderBindings(bindingState)}
|
||||||
${renderDevices(props)}
|
${renderDevices(props)}
|
||||||
<section class="card">
|
<ui-card>
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div slot="header">
|
||||||
<div>
|
<div class="row" style="justify-content: space-between; width: 100%;">
|
||||||
<div class="card-title">Nodes</div>
|
<div>
|
||||||
<div class="card-sub">Paired devices and live links.</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>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="list" style="margin-top: 16px;">
|
<div class="list">
|
||||||
${props.nodes.length === 0
|
${props.nodes.length === 0
|
||||||
? html`<div class="muted">No nodes found.</div>`
|
? html`<div class="muted">No nodes found.</div>`
|
||||||
: props.nodes.map((n) => renderNode(n))}
|
: props.nodes.map((n) => renderNode(n))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ui-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,37 +89,39 @@ function renderDevices(props: NodesProps) {
|
|||||||
const pending = Array.isArray(list.pending) ? list.pending : [];
|
const pending = Array.isArray(list.pending) ? list.pending : [];
|
||||||
const paired = Array.isArray(list.paired) ? list.paired : [];
|
const paired = Array.isArray(list.paired) ? list.paired : [];
|
||||||
return html`
|
return html`
|
||||||
<section class="card">
|
<ui-card>
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div slot="header">
|
||||||
<div>
|
<div class="row" style="justify-content: space-between; width: 100%;">
|
||||||
<div class="card-title">Devices</div>
|
<div>
|
||||||
<div class="card-sub">Pairing requests + role tokens.</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>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
|
||||||
${props.devicesLoading ? "Loading…" : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
${props.devicesError
|
${props.devicesError
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
|
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.devicesError}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="list" style="margin-top: 16px;">
|
<div class="list">
|
||||||
${pending.length > 0
|
${pending.length > 0
|
||||||
? html`
|
? html`
|
||||||
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
||||||
${pending.map((req) => renderPendingDevice(req, props))}
|
${pending.map((req) => renderPendingDevice(req, props))}
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
${paired.length > 0
|
${paired.length > 0
|
||||||
? html`
|
? html`
|
||||||
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
||||||
${paired.map((device) => renderPairedDevice(device, props))}
|
${paired.map((device) => renderPairedDevice(device, props))}
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
${pending.length === 0 && paired.length === 0
|
${pending.length === 0 && paired.length === 0
|
||||||
? html`<div class="muted">No paired devices.</div>`
|
? html`<div class="muted">No paired devices.</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ui-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,12 +142,12 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
|
<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
|
Approve
|
||||||
</button>
|
</ui-button>
|
||||||
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
<ui-button size="sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -157,8 +167,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
|||||||
<div class="list-sub">${device.deviceId}${ip}</div>
|
<div class="list-sub">${device.deviceId}${ip}</div>
|
||||||
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
||||||
${tokens.length === 0
|
${tokens.length === 0
|
||||||
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||||
: html`
|
: html`
|
||||||
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
||||||
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
|
${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="row" style="justify-content: space-between; gap: 8px;">
|
||||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||||
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm"
|
size="sm"
|
||||||
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
||||||
>
|
>
|
||||||
Rotate
|
Rotate
|
||||||
</button>
|
</ui-button>
|
||||||
${token.revokedAtMs
|
${token.revokedAtMs
|
||||||
? nothing
|
? nothing
|
||||||
: html`
|
: html`
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm danger"
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
||||||
>
|
>
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</ui-button>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -399,11 +410,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
|
|||||||
const selectedAgent =
|
const selectedAgent =
|
||||||
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
|
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
|
||||||
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
|
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
|
||||||
null
|
null
|
||||||
: null;
|
: null;
|
||||||
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
|
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
|
||||||
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
|
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
|
||||||
[])
|
[])
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
@ -433,80 +444,71 @@ function renderBindings(state: BindingState) {
|
|||||||
const supportsBinding = state.nodes.length > 0;
|
const supportsBinding = state.nodes.length > 0;
|
||||||
const defaultValue = state.defaultBinding ?? "";
|
const defaultValue = state.defaultBinding ?? "";
|
||||||
return html`
|
return html`
|
||||||
<section class="card">
|
<ui-card>
|
||||||
<div class="row" style="justify-content: space-between; align-items: center;">
|
<div slot="header" style="display: contents;">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">Exec node binding</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>.
|
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
variant="primary"
|
||||||
?disabled=${state.disabled || !state.configDirty}
|
?disabled=${state.disabled || !state.configDirty}
|
||||||
@click=${state.onSave}
|
@click=${state.onSave}
|
||||||
>
|
>
|
||||||
${state.configSaving ? "Saving…" : "Save"}
|
${state.configSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${state.formMode === "raw"
|
${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.
|
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${!state.ready
|
${!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>
|
<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"}
|
${state.configLoading ? "Loading…" : "Load config"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>`
|
</div>`
|
||||||
: html`
|
: html`
|
||||||
<div class="list" style="margin-top: 16px;">
|
<div class="list">
|
||||||
<div class="list-item">
|
<div class="list-item">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
<div class="list-title">Default binding</div>
|
<div class="list-title">Default binding</div>
|
||||||
<div class="list-sub">Used when agents do not override a node binding.</div>
|
<div class="list-sub">Used when agents do not override a node binding.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
<label class="field">
|
<ui-select
|
||||||
<span>Node</span>
|
label="Node"
|
||||||
<select
|
?disabled=${state.disabled || !supportsBinding}
|
||||||
?disabled=${state.disabled || !supportsBinding}
|
.value=${defaultValue}
|
||||||
@change=${(event: Event) => {
|
.options=${[
|
||||||
const target = event.target as HTMLSelectElement;
|
{ label: "Any node", value: "" },
|
||||||
const value = target.value.trim();
|
...state.nodes.map(n => ({ label: n.label, value: n.id }))
|
||||||
state.onBindDefault(value ? value : null);
|
]}
|
||||||
}}
|
@change=${(event: CustomEvent) => {
|
||||||
>
|
const value = event.detail.value.trim();
|
||||||
<option value="" ?selected=${defaultValue === ""}>Any node</option>
|
state.onBindDefault(value ? value : null);
|
||||||
${state.nodes.map(
|
}}
|
||||||
(node) =>
|
></ui-select>
|
||||||
html`<option
|
|
||||||
value=${node.id}
|
|
||||||
?selected=${defaultValue === node.id}
|
|
||||||
>
|
|
||||||
${node.label}
|
|
||||||
</option>`,
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
${!supportsBinding
|
${!supportsBinding
|
||||||
? html`<div class="muted">No nodes with system.run available.</div>`
|
? html`<div class="muted" style="margin-top: 4px; font-size: 11px;">No nodes with system.run available.</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${state.agents.length === 0
|
${state.agents.length === 0
|
||||||
? html`<div class="muted">No agents found.</div>`
|
? html`<div class="muted">No agents found.</div>`
|
||||||
: state.agents.map((agent) =>
|
: state.agents.map((agent) =>
|
||||||
renderAgentBinding(agent, state),
|
renderAgentBinding(agent, state),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</section>
|
</ui-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,40 +516,40 @@ function renderExecApprovals(state: ExecApprovalsState) {
|
|||||||
const ready = state.ready;
|
const ready = state.ready;
|
||||||
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
|
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
|
||||||
return html`
|
return html`
|
||||||
<section class="card">
|
<ui-card>
|
||||||
<div class="row" style="justify-content: space-between; align-items: center;">
|
<div slot="header" style="display: contents;">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">Exec approvals</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>.
|
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ui-button
|
||||||
class="btn"
|
variant="primary"
|
||||||
?disabled=${state.disabled || !state.dirty || !targetReady}
|
?disabled=${state.disabled || !state.dirty || !targetReady}
|
||||||
@click=${state.onSave}
|
@click=${state.onSave}
|
||||||
>
|
>
|
||||||
${state.saving ? "Saving…" : "Save"}
|
${state.saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${renderExecApprovalsTarget(state)}
|
${renderExecApprovalsTarget(state)}
|
||||||
|
|
||||||
${!ready
|
${!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>
|
<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"}
|
${state.loading ? "Loading…" : "Load approvals"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>`
|
</div>`
|
||||||
: html`
|
: html`
|
||||||
${renderExecApprovalsTabs(state)}
|
${renderExecApprovalsTabs(state)}
|
||||||
${renderExecApprovalsPolicy(state)}
|
${renderExecApprovalsPolicy(state)}
|
||||||
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
|
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
|
||||||
? nothing
|
? nothing
|
||||||
: renderExecApprovalsAllowlist(state)}
|
: renderExecApprovalsAllowlist(state)}
|
||||||
`}
|
`}
|
||||||
</section>
|
</ui-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -555,7 +557,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
|
|||||||
const hasNodes = state.targetNodes.length > 0;
|
const hasNodes = state.targetNodes.length > 0;
|
||||||
const nodeValue = state.targetNodeId ?? "";
|
const nodeValue = state.targetNodeId ?? "";
|
||||||
return html`
|
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-item">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
<div class="list-title">Target</div>
|
<div class="list-title">Target</div>
|
||||||
@ -564,56 +566,46 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
<label class="field">
|
<ui-select
|
||||||
<span>Host</span>
|
label="Host"
|
||||||
<select
|
?disabled=${state.disabled}
|
||||||
?disabled=${state.disabled}
|
.value=${state.target}
|
||||||
@change=${(event: Event) => {
|
.options=${[
|
||||||
const target = event.target as HTMLSelectElement;
|
{ label: "Gateway", value: "gateway" },
|
||||||
const value = target.value;
|
{ label: "Node", value: "node" }
|
||||||
if (value === "node") {
|
]}
|
||||||
const first = state.targetNodes[0]?.id ?? null;
|
@change=${(event: CustomEvent) => {
|
||||||
state.onSelectTarget("node", nodeValue || first);
|
const value = event.detail.value;
|
||||||
} else {
|
if (value === "node") {
|
||||||
state.onSelectTarget("gateway", null);
|
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>
|
></ui-select>
|
||||||
</label>
|
|
||||||
${state.target === "node"
|
${state.target === "node"
|
||||||
? html`
|
? html`
|
||||||
<label class="field">
|
<ui-select
|
||||||
<span>Node</span>
|
label="Node"
|
||||||
<select
|
?disabled=${state.disabled || !hasNodes}
|
||||||
?disabled=${state.disabled || !hasNodes}
|
.value=${nodeValue}
|
||||||
@change=${(event: Event) => {
|
.options=${[
|
||||||
const target = event.target as HTMLSelectElement;
|
{ label: "Select node", value: "" },
|
||||||
const value = target.value.trim();
|
...state.targetNodes.map(n => ({ label: n.label, value: n.id }))
|
||||||
state.onSelectTarget("node", value ? value : null);
|
]}
|
||||||
}}
|
@change=${(event: CustomEvent) => {
|
||||||
>
|
const value = event.detail.value.trim();
|
||||||
<option value="" ?selected=${nodeValue === ""}>Select node</option>
|
state.onSelectTarget("node", value ? value : null);
|
||||||
${state.targetNodes.map(
|
}}
|
||||||
(node) =>
|
></ui-select>
|
||||||
html`<option
|
|
||||||
value=${node.id}
|
|
||||||
?selected=${nodeValue === node.id}
|
|
||||||
>
|
|
||||||
${node.label}
|
|
||||||
</option>`,
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${state.target === "node" && !hasNodes
|
${state.target === "node" && !hasNodes
|
||||||
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
|
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -623,23 +615,25 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
|
|||||||
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
|
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
|
||||||
<span class="label">Scope</span>
|
<span class="label">Scope</span>
|
||||||
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
|
size="sm"
|
||||||
|
variant=${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "primary" : "secondary"}
|
||||||
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
|
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
|
||||||
>
|
>
|
||||||
Defaults
|
Defaults
|
||||||
</button>
|
</ui-button>
|
||||||
${state.agents.map((agent) => {
|
${state.agents.map((agent) => {
|
||||||
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
||||||
return html`
|
return html`
|
||||||
<button
|
<ui-button
|
||||||
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
|
size="sm"
|
||||||
|
variant=${state.selectedScope === agent.id ? "primary" : "secondary"}
|
||||||
@click=${() => state.onSelectScope(agent.id)}
|
@click=${() => state.onSelectScope(agent.id)}
|
||||||
>
|
>
|
||||||
${label}
|
${label}
|
||||||
</button>
|
</ui-button>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -671,8 +665,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<div class="list-title">Security</div>
|
<div class="list-title">Security</div>
|
||||||
<div class="list-sub">
|
<div class="list-sub">
|
||||||
${isDefaults
|
${isDefaults
|
||||||
? "Default security mode."
|
? "Default security mode."
|
||||||
: `Default: ${defaults.security}.`}
|
: `Default: ${defaults.security}.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
@ -681,29 +675,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<select
|
<select
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@change=${(event: Event) => {
|
@change=${(event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target as HTMLSelectElement;
|
||||||
const value = target.value;
|
const value = target.value;
|
||||||
if (!isDefaults && value === "__default__") {
|
if (!isDefaults && value === "__default__") {
|
||||||
state.onRemove([...basePath, "security"]);
|
state.onRemove([...basePath, "security"]);
|
||||||
} else {
|
} else {
|
||||||
state.onPatch([...basePath, "security"], value);
|
state.onPatch([...basePath, "security"], value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${!isDefaults
|
${!isDefaults
|
||||||
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
|
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
|
||||||
Use default (${defaults.security})
|
Use default (${defaults.security})
|
||||||
</option>`
|
</option>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${SECURITY_OPTIONS.map(
|
${SECURITY_OPTIONS.map(
|
||||||
(option) =>
|
(option) =>
|
||||||
html`<option
|
html`<option
|
||||||
value=${option.value}
|
value=${option.value}
|
||||||
?selected=${securityValue === option.value}
|
?selected=${securityValue === option.value}
|
||||||
>
|
>
|
||||||
${option.label}
|
${option.label}
|
||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -722,29 +716,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<select
|
<select
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@change=${(event: Event) => {
|
@change=${(event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target as HTMLSelectElement;
|
||||||
const value = target.value;
|
const value = target.value;
|
||||||
if (!isDefaults && value === "__default__") {
|
if (!isDefaults && value === "__default__") {
|
||||||
state.onRemove([...basePath, "ask"]);
|
state.onRemove([...basePath, "ask"]);
|
||||||
} else {
|
} else {
|
||||||
state.onPatch([...basePath, "ask"], value);
|
state.onPatch([...basePath, "ask"], value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${!isDefaults
|
${!isDefaults
|
||||||
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
|
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
|
||||||
Use default (${defaults.ask})
|
Use default (${defaults.ask})
|
||||||
</option>`
|
</option>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${ASK_OPTIONS.map(
|
${ASK_OPTIONS.map(
|
||||||
(option) =>
|
(option) =>
|
||||||
html`<option
|
html`<option
|
||||||
value=${option.value}
|
value=${option.value}
|
||||||
?selected=${askValue === option.value}
|
?selected=${askValue === option.value}
|
||||||
>
|
>
|
||||||
${option.label}
|
${option.label}
|
||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -755,8 +749,8 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<div class="list-title">Ask fallback</div>
|
<div class="list-title">Ask fallback</div>
|
||||||
<div class="list-sub">
|
<div class="list-sub">
|
||||||
${isDefaults
|
${isDefaults
|
||||||
? "Applied when the UI prompt is unavailable."
|
? "Applied when the UI prompt is unavailable."
|
||||||
: `Default: ${defaults.askFallback}.`}
|
: `Default: ${defaults.askFallback}.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
@ -765,29 +759,29 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<select
|
<select
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@change=${(event: Event) => {
|
@change=${(event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target as HTMLSelectElement;
|
||||||
const value = target.value;
|
const value = target.value;
|
||||||
if (!isDefaults && value === "__default__") {
|
if (!isDefaults && value === "__default__") {
|
||||||
state.onRemove([...basePath, "askFallback"]);
|
state.onRemove([...basePath, "askFallback"]);
|
||||||
} else {
|
} else {
|
||||||
state.onPatch([...basePath, "askFallback"], value);
|
state.onPatch([...basePath, "askFallback"], value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${!isDefaults
|
${!isDefaults
|
||||||
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
|
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
|
||||||
Use default (${defaults.askFallback})
|
Use default (${defaults.askFallback})
|
||||||
</option>`
|
</option>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${SECURITY_OPTIONS.map(
|
${SECURITY_OPTIONS.map(
|
||||||
(option) =>
|
(option) =>
|
||||||
html`<option
|
html`<option
|
||||||
value=${option.value}
|
value=${option.value}
|
||||||
?selected=${askFallbackValue === option.value}
|
?selected=${askFallbackValue === option.value}
|
||||||
>
|
>
|
||||||
${option.label}
|
${option.label}
|
||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -798,10 +792,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
<div class="list-title">Auto-allow skill CLIs</div>
|
<div class="list-title">Auto-allow skill CLIs</div>
|
||||||
<div class="list-sub">
|
<div class="list-sub">
|
||||||
${isDefaults
|
${isDefaults
|
||||||
? "Allow skill executables listed by the Gateway."
|
? "Allow skill executables listed by the Gateway."
|
||||||
: autoIsDefault
|
: autoIsDefault
|
||||||
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
|
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
|
||||||
: `Override (${autoEffective ? "on" : "off"}).`}
|
: `Override (${autoEffective ? "on" : "off"}).`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
@ -812,20 +806,20 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
|
|||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
.checked=${autoEffective}
|
.checked=${autoEffective}
|
||||||
@change=${(event: Event) => {
|
@change=${(event: Event) => {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
|
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
${!isDefaults && !autoIsDefault
|
${!isDefaults && !autoIsDefault
|
||||||
? html`<button
|
? html`<button
|
||||||
class="btn btn--sm"
|
class="btn btn--sm"
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
|
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
|
||||||
>
|
>
|
||||||
Use default
|
Use default
|
||||||
</button>`
|
</button>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -845,19 +839,19 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
|
|||||||
class="btn btn--sm"
|
class="btn btn--sm"
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = [...entries, { pattern: "" }];
|
const next = [...entries, { pattern: "" }];
|
||||||
state.onPatch(allowlistPath, next);
|
state.onPatch(allowlistPath, next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add pattern
|
Add pattern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" style="margin-top: 12px;">
|
<div class="list" style="margin-top: 12px;">
|
||||||
${entries.length === 0
|
${entries.length === 0
|
||||||
? html`<div class="muted">No allowlist entries yet.</div>`
|
? html`<div class="muted">No allowlist entries yet.</div>`
|
||||||
: entries.map((entry, index) =>
|
: entries.map((entry, index) =>
|
||||||
renderAllowlistEntry(state, entry, index),
|
renderAllowlistEntry(state, entry, index),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -890,24 +884,24 @@ function renderAllowlistEntry(
|
|||||||
.value=${entry.pattern ?? ""}
|
.value=${entry.pattern ?? ""}
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@input=${(event: Event) => {
|
@input=${(event: Event) => {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
state.onPatch(
|
state.onPatch(
|
||||||
["agents", state.selectedScope, "allowlist", index, "pattern"],
|
["agents", state.selectedScope, "allowlist", index, "pattern"],
|
||||||
target.value,
|
target.value,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm danger"
|
class="btn btn--sm danger"
|
||||||
?disabled=${state.disabled}
|
?disabled=${state.disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (state.allowlist.length <= 1) {
|
if (state.allowlist.length <= 1) {
|
||||||
state.onRemove(["agents", state.selectedScope, "allowlist"]);
|
state.onRemove(["agents", state.selectedScope, "allowlist"]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
|
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@ -927,8 +921,8 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
|||||||
<div class="list-sub">
|
<div class="list-sub">
|
||||||
${agent.isDefault ? "default agent" : "agent"} ·
|
${agent.isDefault ? "default agent" : "agent"} ·
|
||||||
${bindingValue === "__default__"
|
${bindingValue === "__default__"
|
||||||
? `uses default (${state.defaultBinding ?? "any"})`
|
? `uses default (${state.defaultBinding ?? "any"})`
|
||||||
: `override: ${agent.binding}`}
|
: `override: ${agent.binding}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
@ -937,23 +931,23 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
|||||||
<select
|
<select
|
||||||
?disabled=${state.disabled || !supportsBinding}
|
?disabled=${state.disabled || !supportsBinding}
|
||||||
@change=${(event: Event) => {
|
@change=${(event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target as HTMLSelectElement;
|
||||||
const value = target.value.trim();
|
const value = target.value.trim();
|
||||||
state.onBindAgent(agent.index, value === "__default__" ? null : value);
|
state.onBindAgent(agent.index, value === "__default__" ? null : value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="__default__" ?selected=${bindingValue === "__default__"}>
|
<option value="__default__" ?selected=${bindingValue === "__default__"}>
|
||||||
Use default
|
Use default
|
||||||
</option>
|
</option>
|
||||||
${state.nodes.map(
|
${state.nodes.map(
|
||||||
(node) =>
|
(node) =>
|
||||||
html`<option
|
html`<option
|
||||||
value=${node.id}
|
value=${node.id}
|
||||||
?selected=${bindingValue === node.id}
|
?selected=${bindingValue === node.id}
|
||||||
>
|
>
|
||||||
${node.label}
|
${node.label}
|
||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -1078,8 +1072,8 @@ function renderNode(node: Record<string, unknown>) {
|
|||||||
</span>
|
</span>
|
||||||
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
|
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||||
${commands
|
${commands
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import type { GatewayHelloOk } from "../gateway";
|
|||||||
import { formatAgo, formatDurationMs } from "../format";
|
import { formatAgo, formatDurationMs } from "../format";
|
||||||
import { formatNextRun } from "../presenter";
|
import { formatNextRun } from "../presenter";
|
||||||
import type { UiSettings } from "../storage";
|
import type { UiSettings } from "../storage";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/input";
|
||||||
|
import "../components/card";
|
||||||
|
import "../components/badge";
|
||||||
|
|
||||||
export type OverviewProps = {
|
export type OverviewProps = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@ -118,70 +122,64 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid grid-cols-2">
|
||||||
<div class="card">
|
<ui-card>
|
||||||
<div class="card-title">Gateway Access</div>
|
<div slot="header">
|
||||||
<div class="card-sub">Where the dashboard connects and how it authenticates.</div>
|
<div class="card-title">Gateway Access</div>
|
||||||
<div class="form-grid" style="margin-top: 16px;">
|
<div class="card-desc">Where the dashboard connects and how it authenticates.</div>
|
||||||
<label class="field">
|
</div>
|
||||||
<span>WebSocket URL</span>
|
<div class="form-grid">
|
||||||
<input
|
<ui-input
|
||||||
.value=${props.settings.gatewayUrl}
|
label="WebSocket URL"
|
||||||
@input=${(e: Event) => {
|
.value=${props.settings.gatewayUrl}
|
||||||
const v = (e.target as HTMLInputElement).value;
|
placeholder="ws://100.x.y.z:18789"
|
||||||
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
|
@input=${(e: CustomEvent) => {
|
||||||
}}
|
props.onSettingsChange({ ...props.settings, gatewayUrl: e.detail.value });
|
||||||
placeholder="ws://100.x.y.z:18789"
|
}}
|
||||||
/>
|
></ui-input>
|
||||||
</label>
|
<ui-input
|
||||||
<label class="field">
|
label="Gateway Token"
|
||||||
<span>Gateway Token</span>
|
.value=${props.settings.token}
|
||||||
<input
|
placeholder="CLAWDBOT_GATEWAY_TOKEN"
|
||||||
.value=${props.settings.token}
|
@input=${(e: CustomEvent) => {
|
||||||
@input=${(e: Event) => {
|
props.onSettingsChange({ ...props.settings, token: e.detail.value });
|
||||||
const v = (e.target as HTMLInputElement).value;
|
}}
|
||||||
props.onSettingsChange({ ...props.settings, token: v });
|
></ui-input>
|
||||||
}}
|
<ui-input
|
||||||
placeholder="CLAWDBOT_GATEWAY_TOKEN"
|
label="Password (not stored)"
|
||||||
/>
|
type="password"
|
||||||
</label>
|
.value=${props.password}
|
||||||
<label class="field">
|
placeholder="system or shared password"
|
||||||
<span>Password (not stored)</span>
|
@input=${(e: CustomEvent) => {
|
||||||
<input
|
props.onPasswordChange(e.detail.value);
|
||||||
type="password"
|
}}
|
||||||
.value=${props.password}
|
></ui-input>
|
||||||
@input=${(e: Event) => {
|
<ui-input
|
||||||
const v = (e.target as HTMLInputElement).value;
|
label="Default Session Key"
|
||||||
props.onPasswordChange(v);
|
.value=${props.settings.sessionKey}
|
||||||
}}
|
@input=${(e: CustomEvent) => {
|
||||||
placeholder="system or shared password"
|
props.onSessionKeyChange(e.detail.value);
|
||||||
/>
|
}}
|
||||||
</label>
|
></ui-input>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 14px;">
|
<div class="row" style="margin-top: 14px;">
|
||||||
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
|
<ui-button variant="primary" @click=${() => props.onConnect()}>Connect</ui-button>
|
||||||
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
|
<ui-button variant="secondary" @click=${() => props.onRefresh()}>Refresh</ui-button>
|
||||||
<span class="muted">Click Connect to apply connection changes.</span>
|
<span class="muted">Click Connect to apply connection changes.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ui-card>
|
||||||
|
|
||||||
<div class="card">
|
<ui-card>
|
||||||
<div class="card-title">Snapshot</div>
|
<div slot="header">
|
||||||
<div class="card-sub">Latest gateway handshake information.</div>
|
<div class="card-title">Snapshot</div>
|
||||||
<div class="stat-grid" style="margin-top: 16px;">
|
<div class="card-desc">Latest gateway handshake information.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-grid">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Status</div>
|
<div class="stat-label">Status</div>
|
||||||
<div class="stat-value ${props.connected ? "ok" : "warn"}">
|
<div class="stat-value">
|
||||||
${props.connected ? "Connected" : "Disconnected"}
|
<ui-badge variant=${props.connected ? "success" : "secondary"}>
|
||||||
|
${props.connected ? "Connected" : "Disconnected"}
|
||||||
|
</ui-badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@ -210,21 +208,21 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
: html`<div class="callout" style="margin-top: 14px;">
|
: html`<div class="callout" style="margin-top: 14px;">
|
||||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>
|
</ui-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid grid-cols-3" style="margin-top: 18px;">
|
<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-label">Instances</div>
|
||||||
<div class="stat-value">${props.presenceCount}</div>
|
<div class="stat-value">${props.presenceCount}</div>
|
||||||
<div class="muted">Presence beacons in the last 5 minutes.</div>
|
<div class="muted">Presence beacons in the last 5 minutes.</div>
|
||||||
</div>
|
</ui-card>
|
||||||
<div class="card stat-card">
|
<ui-card class="stat-card">
|
||||||
<div class="stat-label">Sessions</div>
|
<div class="stat-label">Sessions</div>
|
||||||
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
|
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
|
||||||
<div class="muted">Recent session keys tracked by the gateway.</div>
|
<div class="muted">Recent session keys tracked by the gateway.</div>
|
||||||
</div>
|
</ui-card>
|
||||||
<div class="card stat-card">
|
<ui-card class="stat-card">
|
||||||
<div class="stat-label">Cron</div>
|
<div class="stat-label">Cron</div>
|
||||||
<div class="stat-value">
|
<div class="stat-value">
|
||||||
${props.cronEnabled == null
|
${props.cronEnabled == null
|
||||||
@ -234,13 +232,15 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
: "Disabled"}
|
: "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
|
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
|
||||||
</div>
|
</ui-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" style="margin-top: 18px;">
|
<ui-card style="margin-top: 18px;">
|
||||||
<div class="card-title">Notes</div>
|
<div slot="header">
|
||||||
<div class="card-sub">Quick reminders for remote control setups.</div>
|
<div class="card-title">Notes</div>
|
||||||
<div class="note-grid" style="margin-top: 14px;">
|
<div class="card-desc">Quick reminders for remote control setups.</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="note-title">Tailscale serve</div>
|
<div class="note-title">Tailscale serve</div>
|
||||||
<div class="muted">
|
<div class="muted">
|
||||||
@ -256,6 +256,6 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
<div class="muted">Use isolated sessions for recurring runs.</div>
|
<div class="muted">Use isolated sessions for recurring runs.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ui-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { formatAgo } from "../format";
|
|||||||
import { formatSessionTokens } from "../presenter";
|
import { formatSessionTokens } from "../presenter";
|
||||||
import { pathForTab } from "../navigation";
|
import { pathForTab } from "../navigation";
|
||||||
import type { GatewaySessionRow, SessionsListResult } from "../types";
|
import type { GatewaySessionRow, SessionsListResult } from "../types";
|
||||||
|
import "../components/button";
|
||||||
|
import "../components/input";
|
||||||
|
import "../components/checkbox";
|
||||||
|
|
||||||
export type SessionsProps = {
|
export type SessionsProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -79,71 +82,65 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
<div class="card-title">Sessions</div>
|
<div class="card-title">Sessions</div>
|
||||||
<div class="card-sub">Active session keys and per-session overrides.</div>
|
<div class="card-sub">Active session keys and per-session overrides.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
<ui-button ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 14px;">
|
<div style="margin-top: 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
|
||||||
<label class="field">
|
<ui-input
|
||||||
<span>Active within (minutes)</span>
|
label="Active within (minutes)"
|
||||||
<input
|
.value=${props.activeMinutes}
|
||||||
.value=${props.activeMinutes}
|
@input=${(e: CustomEvent) =>
|
||||||
@input=${(e: Event) =>
|
props.onFiltersChange({
|
||||||
props.onFiltersChange({
|
activeMinutes: e.detail.value,
|
||||||
activeMinutes: (e.target as HTMLInputElement).value,
|
limit: props.limit,
|
||||||
limit: props.limit,
|
includeGlobal: props.includeGlobal,
|
||||||
includeGlobal: props.includeGlobal,
|
includeUnknown: props.includeUnknown,
|
||||||
includeUnknown: props.includeUnknown,
|
})}
|
||||||
})}
|
style="width: 200px;"
|
||||||
/>
|
></ui-input>
|
||||||
</label>
|
<ui-input
|
||||||
<label class="field">
|
label="Limit"
|
||||||
<span>Limit</span>
|
.value=${props.limit}
|
||||||
<input
|
@input=${(e: CustomEvent) =>
|
||||||
.value=${props.limit}
|
props.onFiltersChange({
|
||||||
@input=${(e: Event) =>
|
activeMinutes: props.activeMinutes,
|
||||||
props.onFiltersChange({
|
limit: e.detail.value,
|
||||||
activeMinutes: props.activeMinutes,
|
includeGlobal: props.includeGlobal,
|
||||||
limit: (e.target as HTMLInputElement).value,
|
includeUnknown: props.includeUnknown,
|
||||||
includeGlobal: props.includeGlobal,
|
})}
|
||||||
includeUnknown: props.includeUnknown,
|
style="width: 120px;"
|
||||||
})}
|
></ui-input>
|
||||||
/>
|
<div style="display: flex; gap: 16px; align-items: center; margin-top: 19px;">
|
||||||
</label>
|
<ui-checkbox
|
||||||
<label class="field checkbox">
|
label="Include global"
|
||||||
<span>Include global</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
.checked=${props.includeGlobal}
|
.checked=${props.includeGlobal}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: CustomEvent) =>
|
||||||
props.onFiltersChange({
|
props.onFiltersChange({
|
||||||
activeMinutes: props.activeMinutes,
|
activeMinutes: props.activeMinutes,
|
||||||
limit: props.limit,
|
limit: props.limit,
|
||||||
includeGlobal: (e.target as HTMLInputElement).checked,
|
includeGlobal: e.detail.checked,
|
||||||
includeUnknown: props.includeUnknown,
|
includeUnknown: props.includeUnknown,
|
||||||
})}
|
})}
|
||||||
/>
|
></ui-checkbox>
|
||||||
</label>
|
<ui-checkbox
|
||||||
<label class="field checkbox">
|
label="Include unknown"
|
||||||
<span>Include unknown</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
.checked=${props.includeUnknown}
|
.checked=${props.includeUnknown}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: CustomEvent) =>
|
||||||
props.onFiltersChange({
|
props.onFiltersChange({
|
||||||
activeMinutes: props.activeMinutes,
|
activeMinutes: props.activeMinutes,
|
||||||
limit: props.limit,
|
limit: props.limit,
|
||||||
includeGlobal: props.includeGlobal,
|
includeGlobal: props.includeGlobal,
|
||||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
includeUnknown: e.detail.checked,
|
||||||
})}
|
})}
|
||||||
/>
|
></ui-checkbox>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${props.error
|
${props.error
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div class="muted" style="margin-top: 12px;">
|
<div class="muted" style="margin-top: 12px;">
|
||||||
${props.result ? `Store: ${props.result.path}` : ""}
|
${props.result ? `Store: ${props.result.path}` : ""}
|
||||||
@ -162,10 +159,10 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
<div>Actions</div>
|
<div>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
${rows.length === 0
|
${rows.length === 0
|
||||||
? html`<div class="muted">No sessions found.</div>`
|
? html`<div class="muted">No sessions found.</div>`
|
||||||
: rows.map((row) =>
|
: rows.map((row) =>
|
||||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@ -194,17 +191,17 @@ function renderRow(
|
|||||||
return html`
|
return html`
|
||||||
<div class="table-row">
|
<div class="table-row">
|
||||||
<div class="mono">${canLink
|
<div class="mono">${canLink
|
||||||
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
|
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
|
||||||
: displayName}</div>
|
: displayName}</div>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
.value=${row.label ?? ""}
|
.value=${row.label ?? ""}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
placeholder="(optional)"
|
placeholder="(optional)"
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLInputElement).value.trim();
|
const value = (e.target as HTMLInputElement).value.trim();
|
||||||
onPatch(row.key, { label: value || null });
|
onPatch(row.key, { label: value || null });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>${row.kind}</div>
|
<div>${row.kind}</div>
|
||||||
@ -215,15 +212,15 @@ function renderRow(
|
|||||||
.value=${thinking}
|
.value=${thinking}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, {
|
onPatch(row.key, {
|
||||||
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
|
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${thinkLevels.map((level) =>
|
${thinkLevels.map((level) =>
|
||||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -231,13 +228,13 @@ function renderRow(
|
|||||||
.value=${verbose}
|
.value=${verbose}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, { verboseLevel: value || null });
|
onPatch(row.key, { verboseLevel: value || null });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${VERBOSE_LEVELS.map(
|
${VERBOSE_LEVELS.map(
|
||||||
(level) => html`<option value=${level.value}>${level.label}</option>`,
|
(level) => html`<option value=${level.value}>${level.label}</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -245,19 +242,19 @@ function renderRow(
|
|||||||
.value=${reasoning}
|
.value=${reasoning}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, { reasoningLevel: value || null });
|
onPatch(row.key, { reasoningLevel: value || null });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${REASONING_LEVELS.map((level) =>
|
${REASONING_LEVELS.map((level) =>
|
||||||
html`<option value=${level}>${level || "inherit"}</option>`,
|
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
<ui-button variant="danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</ui-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user