diff --git a/ui/src/components/shared/ccs-logo.tsx b/ui/src/components/shared/ccs-logo.tsx new file mode 100644 index 00000000..58e6eda6 --- /dev/null +++ b/ui/src/components/shared/ccs-logo.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/lib/utils'; + +interface CcsLogoProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; + showText?: boolean; +} + +const sizeMap = { + sm: 24, + md: 32, + lg: 48, +}; + +export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) { + const dimension = sizeMap[size]; + + return ( +
+ CCS Logo + {showText && CCS Config} +
+ ); +} diff --git a/ui/src/components/shared/claudekit-badge.tsx b/ui/src/components/shared/claudekit-badge.tsx new file mode 100644 index 00000000..a8c4308b --- /dev/null +++ b/ui/src/components/shared/claudekit-badge.tsx @@ -0,0 +1,49 @@ +/** + * ClaudeKit Badge Button + * + * "Powered by ClaudeKit" badge for navbar, inspired by landing page design. + * Compact version optimized for header placement. + */ + +import { cn } from '@/lib/utils'; + +const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN'; + +export function ClaudeKitBadge() { + return ( + + ClaudeKit + + + Powered by + + + ClaudeKit + + + + ); +} diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx new file mode 100644 index 00000000..47f95edc --- /dev/null +++ b/ui/src/components/shared/code-editor.tsx @@ -0,0 +1,221 @@ +/** + * Code Editor Component + * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) + */ + +import { useState, useCallback, useMemo } from 'react'; +import Editor from 'react-simple-code-editor'; +import { Highlight, themes } from 'prism-react-renderer'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { isSensitiveKey } from '@/lib/sensitive-keys'; +import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + language?: 'json' | 'yaml'; + readonly?: boolean; + className?: string; + minHeight?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; +} + +/** + * Validate JSON and extract error location + */ +function validateJson(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + JSON.parse(code); + return { valid: true }; + } catch (e) { + const error = e as SyntaxError; + const message = error.message; + + // Try to extract line number from error message + // Format: "... at position X" or "... at line Y column Z" + const posMatch = message.match(/position (\d+)/); + if (posMatch) { + const pos = parseInt(posMatch[1], 10); + const lines = code.substring(0, pos).split('\n'); + return { + valid: false, + error: message, + line: lines.length, + }; + } + + return { + valid: false, + error: message, + }; + } +} + +export function CodeEditor({ + value, + onChange, + language = 'json', + readonly = false, + className, + minHeight = '300px', +}: CodeEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + const [isMasked, setIsMasked] = useState(true); + + // Validate on every change for JSON + const validation = useMemo(() => { + if (language === 'json') { + return validateJson(value); + } + return { valid: true }; + }, [value, language]); + + // Highlight function using prism-react-renderer + // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => { + let nextValueIsSensitive = false; + + return ( + <> + {tokens.map((line, i) => ( +
+ {line.map((token, key) => { + let isSensitive = false; + + // Check for sensitive keys + if (token.types.includes('property')) { + const content = token.content.replace(/['"]/g, ''); + // Use shared sensitive key detection utility + if (isSensitiveKey(content)) { + nextValueIsSensitive = true; + } else { + nextValueIsSensitive = false; + } + } + // Apply masking to values following sensitive keys + else if ( + (token.types.includes('string') || + token.types.includes('number') || + token.types.includes('boolean')) && + nextValueIsSensitive + ) { + isSensitive = true; + // Consumes the flag for this value + nextValueIsSensitive = false; + } + // Reset flag on commas or new keys (handled by property check), + // but persist through colons and whitespace + else if (token.types.includes('punctuation')) { + if (token.content !== ':' && token.content !== '[' && token.content !== '{') { + nextValueIsSensitive = false; + } + } + + const tokenProps = getTokenProps({ token }); + + if (isSensitive && isMasked) { + tokenProps.className = cn( + tokenProps.className, + 'blur-[3px] select-none opacity-70 transition-all duration-200' + ); + } + + return ; + })} +
+ ))} + + ); + }} +
+ ), + [isDark, language, validation.line, isMasked] + ); + + return ( +
+ {/* Editor container */} +
+ {} : onChange} + highlight={highlightCode} + key={isDark ? 'dark-editor' : 'light-editor'} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> + + {/* Secrets Toggle Overlay */} +
+ +
+
+ + {/* Validation status */} +
+ {validation.valid ? ( + + + Valid {language.toUpperCase()} + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + {readonly && (Read-only)} +
+
+ ); +} diff --git a/ui/src/components/shared/command-builder.tsx b/ui/src/components/shared/command-builder.tsx new file mode 100644 index 00000000..8e33dae3 --- /dev/null +++ b/ui/src/components/shared/command-builder.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react'; + +interface Command { + id: string; + command: string; + description: string; + category: string; +} + +const commonCommands: Command[] = [ + { + id: '1', + command: 'ccs config', + description: 'Open configuration interface', + category: 'Config', + }, + { + id: '2', + command: 'ccs profile create --name my-profile', + description: 'Create a new profile', + category: 'Profile', + }, + { + id: '3', + command: 'ccs profile switch --name my-profile', + description: 'Switch to a profile', + category: 'Profile', + }, + { + id: '4', + command: 'ccs doctor', + description: 'Check system health', + category: 'Diagnostics', + }, + { + id: '5', + command: 'ccs cliproxy list', + description: 'List available CLIProxy providers', + category: 'CLIProxy', + }, + { + id: '6', + command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN', + description: 'Add CLIProxy provider', + category: 'CLIProxy', + }, +]; + +export function CommandBuilder() { + const [command, setCommand] = useState(''); + const [filteredCommands, setFilteredCommands] = useState(commonCommands); + + const handleCommandChange = (value: string) => { + setCommand(value); + const filtered = commonCommands.filter( + (cmd) => + cmd.command.toLowerCase().includes(value.toLowerCase()) || + cmd.description.toLowerCase().includes(value.toLowerCase()) + ); + setFilteredCommands(filtered); + }; + + const handleCommandSelect = (cmd: string) => { + setCommand(cmd); + setFilteredCommands(commonCommands); + }; + + const handleCopy = () => { + navigator.clipboard.writeText(command); + }; + + const handleRun = () => { + console.log('Running command:', command); + }; + + const categories = Array.from(new Set(commonCommands.map((cmd) => cmd.category))); + + return ( + + + + + Command Builder + + + +
+ handleCommandChange(e.target.value)} + className="font-mono" + /> +
+ + +
+
+ +
+ {categories.map((category) => ( + { + const categoryCommands = commonCommands.filter((cmd) => cmd.category === category); + console.log(`${category} commands:`, categoryCommands); + }} + > + {category} + + ))} +
+ + +
+ {filteredCommands.map((cmd) => ( +
handleCommandSelect(cmd.command)} + > +
{cmd.command}
+
{cmd.description}
+ + {cmd.category} + +
+ ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/shared/confirm-dialog.tsx b/ui/src/components/shared/confirm-dialog.tsx new file mode 100644 index 00000000..43d7f940 --- /dev/null +++ b/ui/src/components/shared/confirm-dialog.tsx @@ -0,0 +1,50 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface ConfirmDialogProps { + open: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + description: string; + confirmText?: string; + variant?: 'default' | 'destructive'; +} + +export function ConfirmDialog({ + open, + onConfirm, + onCancel, + title, + description, + confirmText = 'Confirm', + variant = 'default', +}: ConfirmDialogProps) { + return ( + !isOpen && onCancel()}> + + + {title} + {description} + + + Cancel + + {confirmText} + + + + + ); +} diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx new file mode 100644 index 00000000..eb00487d --- /dev/null +++ b/ui/src/components/shared/connection-indicator.tsx @@ -0,0 +1,28 @@ +/** + * Connection Indicator (Phase 04) + * + * Shows WebSocket connection status in the header. + */ + +import { Wifi, WifiOff } from 'lucide-react'; +import { useWebSocket } from '@/hooks/use-websocket'; + +export function ConnectionIndicator() { + const { status } = useWebSocket(); + + const statusConfig = { + connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, + connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' }, + disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + }; + + const config = statusConfig[status]; + const Icon = config.icon; + + return ( +
+ + {config.label} +
+ ); +} diff --git a/ui/src/components/shared/docs-link.tsx b/ui/src/components/shared/docs-link.tsx new file mode 100644 index 00000000..72e16014 --- /dev/null +++ b/ui/src/components/shared/docs-link.tsx @@ -0,0 +1,20 @@ +/** + * Docs Link Button + * + * Links to CCS documentation site for guides and reference. + */ + +import { BookOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCS_URL = 'https://docs.ccs.kaitran.ca'; + +export function DocsLink() { + return ( + + ); +} diff --git a/ui/src/components/shared/github-link.tsx b/ui/src/components/shared/github-link.tsx new file mode 100644 index 00000000..8ece4cab --- /dev/null +++ b/ui/src/components/shared/github-link.tsx @@ -0,0 +1,20 @@ +/** + * GitHub Link Button + * + * Links to CCS GitHub issues page for bug reports and feature requests. + */ + +import { Github } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues'; + +export function GitHubLink() { + return ( + + ); +} diff --git a/ui/src/components/shared/global-env-indicator.tsx b/ui/src/components/shared/global-env-indicator.tsx new file mode 100644 index 00000000..b6cc0cf3 --- /dev/null +++ b/ui/src/components/shared/global-env-indicator.tsx @@ -0,0 +1,132 @@ +/** + * Global Environment Variables Indicator + * + * Shows which env vars from global_env will be injected at runtime. + * Displayed below the Raw Configuration (JSON) section in profile editors. + */ + +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + +interface GlobalEnvIndicatorProps { + /** Current profile's env vars (to show which are overridden) */ + profileEnv?: Record; +} + +export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + try { + setLoading(true); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + setConfig(data); + } catch { + setConfig(null); + } finally { + setLoading(false); + } + }; + + // Don't render if loading or disabled or no vars + if (loading) return null; + if (!config?.enabled) return null; + + const envVars = config.env || {}; + const envKeys = Object.keys(envVars); + if (envKeys.length === 0) return null; + + // Check which keys are already in profile (won't be overridden) + const injectedKeys = envKeys.filter((key) => !(key in profileEnv)); + const overriddenKeys = envKeys.filter((key) => key in profileEnv); + + return ( +
+ {/* Header - clickable to expand */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* Injected vars */} + {injectedKeys.length > 0 && ( +
+ {injectedKeys.map((key) => ( +
+ + + + {key}={envVars[key]} + +
+ ))} +
+ )} + + {/* Overridden vars (profile takes precedence) */} + {overriddenKeys.length > 0 && ( +
+

Skipped (profile already defines):

+ {overriddenKeys.map((key) => ( +
+ ~ + {key} +
+ ))} +
+ )} + + {/* Link to settings */} +
+ +
+
+ )} +
+ ); +} diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts new file mode 100644 index 00000000..2a181ec4 --- /dev/null +++ b/ui/src/components/shared/index.ts @@ -0,0 +1,22 @@ +/** + * Shared Components Barrel Export + */ + +export { CcsLogo } from './ccs-logo'; +export { ClaudeKitBadge } from './claudekit-badge'; +export { CodeEditor } from './code-editor'; +export { CommandBuilder } from './command-builder'; +export { ConfirmDialog } from './confirm-dialog'; +export { ConnectionIndicator } from './connection-indicator'; +export { DocsLink } from './docs-link'; +export { GitHubLink } from './github-link'; +export { GlobalEnvIndicator } from './global-env-indicator'; +export { LocalhostDisclaimer } from './localhost-disclaimer'; +export { PrivacyToggle } from './privacy-toggle'; +export { ProjectSelectionDialog } from './project-selection-dialog'; +export { ProviderIcon } from './provider-icon'; +export { QuickCommands } from './quick-commands'; +export { SettingsDialog } from './settings-dialog'; +export { SponsorButton } from './sponsor-button'; +export { StatCard } from './stat-card'; +export { ValueMetrics } from './value-metrics'; diff --git a/ui/src/components/shared/localhost-disclaimer.tsx b/ui/src/components/shared/localhost-disclaimer.tsx new file mode 100644 index 00000000..78d5c6e1 --- /dev/null +++ b/ui/src/components/shared/localhost-disclaimer.tsx @@ -0,0 +1,29 @@ +import { Shield, X } from 'lucide-react'; +import { useState } from 'react'; + +export function LocalhostDisclaimer() { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) return null; + + return ( +
+
+
+ + + This dashboard runs locally. All data stays on your machine. + + Local dashboard - data stays on your device. +
+ +
+
+ ); +} diff --git a/ui/src/components/shared/privacy-toggle.tsx b/ui/src/components/shared/privacy-toggle.tsx new file mode 100644 index 00000000..eb70362d --- /dev/null +++ b/ui/src/components/shared/privacy-toggle.tsx @@ -0,0 +1,39 @@ +/** + * Privacy Toggle Button + * Toggles demo mode to blur personal information (emails, account IDs) + */ + +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { usePrivacy } from '@/contexts/privacy-context'; +import { Eye, EyeOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export function PrivacyToggle() { + const { privacyMode, togglePrivacyMode } = usePrivacy(); + + return ( + + + + + +

+ {privacyMode + ? 'Privacy mode ON - Click to show data' + : 'Privacy mode OFF - Click to hide data'} +

+
+
+ ); +} diff --git a/ui/src/components/shared/project-selection-dialog.tsx b/ui/src/components/shared/project-selection-dialog.tsx new file mode 100644 index 00000000..b80b00b9 --- /dev/null +++ b/ui/src/components/shared/project-selection-dialog.tsx @@ -0,0 +1,203 @@ +/** + * Project Selection Dialog Component + * + * Displays during OAuth flow when CLIProxyAPI requires user to select + * a Google Cloud project. Shows list of available projects with option + * to select one or ALL. + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react'; + +interface GCloudProject { + id: string; + name: string; + index: number; +} + +interface ProjectSelectionDialogProps { + open: boolean; + onClose: () => void; + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; + onSelect: (selectedId: string) => Promise; + /** Timeout in seconds before auto-selecting default */ + timeoutSeconds?: number; +} + +export function ProjectSelectionDialog({ + open, + onClose, + sessionId, + provider, + projects, + defaultProjectId, + supportsAll, + onSelect, + timeoutSeconds = 30, +}: ProjectSelectionDialogProps) { + const [selectedId, setSelectedId] = useState(defaultProjectId); + const [isSubmitting, setIsSubmitting] = useState(false); + const [countdown, setCountdown] = useState(timeoutSeconds); + + // Countdown timer for auto-selection + useEffect(() => { + if (!open) { + setCountdown(timeoutSeconds); + return; + } + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + // Auto-submit on timeout + handleSubmit(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, timeoutSeconds]); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedId(defaultProjectId); + setIsSubmitting(false); + setCountdown(timeoutSeconds); + } + }, [open, defaultProjectId, timeoutSeconds]); + + const handleSubmit = async (isTimeout = false) => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + // Empty string means use default (press Enter behavior) + const submitId = isTimeout ? '' : selectedId; + await onSelect(submitId); + onClose(); + } catch (error) { + console.error('Failed to submit project selection:', error); + // On error, submit empty to use default + try { + await onSelect(''); + } catch { + // Ignore double-error + } + onClose(); + } + }; + + const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); + + // Suppress unused variable warning - sessionId used for identification + void sessionId; + + return ( + !isOpen && !isSubmitting && onClose()}> + + + + + Select Google Cloud Project + + + Choose which project to use for {providerDisplay} authentication. + {countdown > 0 && ( + + (Auto-selecting default in {countdown}s) + + )} + + + +
+
+ {projects.map((project) => ( +
!isSubmitting && setSelectedId(project.id)} + > + {selectedId === project.id ? ( + + ) : ( + + )} +
+
{project.name}
+
{project.id}
+
+ {project.id === defaultProjectId && ( + Default + )} +
+ ))} + + {supportsAll && ( +
!isSubmitting && setSelectedId('ALL')} + > + {selectedId === 'ALL' ? ( + + ) : ( + + )} +
+
All Projects
+
+ Onboard all {projects.length} listed projects +
+
+
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/shared/provider-icon.tsx b/ui/src/components/shared/provider-icon.tsx new file mode 100644 index 00000000..828f649e --- /dev/null +++ b/ui/src/components/shared/provider-icon.tsx @@ -0,0 +1,95 @@ +/** + * Provider Icon Component + * Renders provider logos from /assets/providers/ + * Supports white background circle variant for dark themes + */ + +import { cn } from '@/lib/utils'; +import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config'; + +interface ProviderIconProps { + provider: string; + className?: string; + size?: number; + /** White background circle variant for better visibility */ + withBackground?: boolean; +} + +export function ProviderIcon({ + provider, + className, + size = 18, + withBackground = false, +}: ProviderIconProps) { + const normalized = provider.toLowerCase(); + const assetPath = PROVIDER_ASSETS[normalized]; + + // Icon size is smaller when inside background circle + const iconSize = withBackground ? Math.floor(size * 0.65) : size; + + const iconElement = assetPath ? ( + {`${provider} + ) : ( + // Fallback: colored text letter + + {provider.charAt(0).toUpperCase()} + + ); + + if (withBackground) { + return ( +
+ {iconElement} +
+ ); + } + + // Without background - original behavior for logos, colored circle for fallback + if (assetPath) { + return ( + {`${provider} + ); + } + + const bgColor = PROVIDER_COLORS[normalized] || '#6b7280'; + return ( +
+ {provider.charAt(0).toUpperCase()} +
+ ); +} diff --git a/ui/src/components/shared/quick-commands.tsx b/ui/src/components/shared/quick-commands.tsx new file mode 100644 index 00000000..6ab4d6f8 --- /dev/null +++ b/ui/src/components/shared/quick-commands.tsx @@ -0,0 +1,92 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Copy, Check, Terminal } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; + +interface CommandSnippet { + label: string; + command: string; + description: string; +} + +const defaultSnippets: CommandSnippet[] = [ + { + label: 'Start Default', + command: 'ccs', + description: 'Launch Claude with default profile', + }, + { + label: 'GLM Profile', + command: 'ccs glm', + description: 'Switch to GLM model', + }, + { + label: 'Health Check', + command: 'ccs doctor', + description: 'Run system diagnostics', + }, + { + label: 'Delegate Task', + command: 'ccs glm -p "your task"', + description: 'Delegate to GLM profile', + }, +]; + +interface QuickCommandsProps { + snippets?: CommandSnippet[]; +} + +export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) { + const [copiedIndex, setCopiedIndex] = useState(null); + + const copyToClipboard = async (text: string, index: number) => { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + return ( + + + + + Quick Commands + + + +
+ {snippets.map((snippet, index) => ( +
+
+

{snippet.label}

+ + {snippet.command} + +
+ +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx new file mode 100644 index 00000000..f0f6c760 --- /dev/null +++ b/ui/src/components/shared/settings-dialog.tsx @@ -0,0 +1,385 @@ +/** + * Settings Dialog Component + * Reusable dialog for editing profile environment variables + * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor + */ + +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { ConfirmDialog } from './confirm-dialog'; +import { Save, X, Loader2, Code2 } from 'lucide-react'; +import { toast } from 'sonner'; + +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor }))); + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface SettingsDialogProps { + open: boolean; + onClose: () => void; + profileName: string | null; +} + +/** + * Inner component that manages local edits state + * Gets unmounted/remounted via key prop when dialog closes/opens + */ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +function SettingsDialogContent({ + profileName, + onClose, +}: { + profileName: string; + onClose: () => void; +}) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive raw JSON content: use edits if available, otherwise serialize from data + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + // Update raw JSON when user edits + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + const settings = data?.settings; + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [data?.settings, localEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + // Determine what to save based on active tab + if (activeTab === 'raw') { + // Parse raw JSON content + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + // Use form-based edits + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + toast.success('Settings saved'); + onClose(); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + // Refetch to get new mtime, then save + await refetch(); + saveMutation.mutate(); + } else { + // Discard local changes and close + onClose(); + } + }; + + const updateEnvValue = (key: string, value: string) => { + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const isSensitiveKey = (key: string): boolean => { + // Pattern-based matching for sensitive keys (same as backend) + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_API_KEY$/, // Keys ending with _API_KEY + /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN + /^API_KEY$/, // Exact match for API_KEY + /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN + /_SECRET$/, // Keys ending with _SECRET + /^SECRET$/, // Exact match for SECRET + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); + }; + + return ( + <> + + Edit Profile: {profileName} + + Configure environment variables and settings for this profile. + + + + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( +
+ + + + Environment + + + + Raw JSON + + + General + + + + + + {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( +
+ {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + )} +
+ ))} +
+ ) : ( +
+

No environment variables configured.

+

Add variables in your settings.json file.

+
+ )} +
+
+ + + + + Loading editor... +
+ } + > + + + + + + + + Profile Information + Details about this configuration file. + + + {data && ( + <> +
+ Path + + {data.path} + +
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+
+ + +
+ + +
+ + )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> + + ); +} + +export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) { + // Handle dialog open/close state changes + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + onClose(); + } + }, + [onClose] + ); + + return ( + + + {/* Key prop ensures fresh state on each open */} + {open && profileName && ( + + )} + + + ); +} diff --git a/ui/src/components/shared/sponsor-button.tsx b/ui/src/components/shared/sponsor-button.tsx new file mode 100644 index 00000000..382d9afb --- /dev/null +++ b/ui/src/components/shared/sponsor-button.tsx @@ -0,0 +1,46 @@ +/** + * Sponsor Button + * + * GitHub Sponsors button for navbar. + * Heart icon with hover animation. + */ + +import { Heart } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt'; + +export function SponsorButton() { + return ( + + + + Sponsor + + + ); +} diff --git a/ui/src/components/shared/stat-card.tsx b/ui/src/components/shared/stat-card.tsx new file mode 100644 index 00000000..8ef7f37b --- /dev/null +++ b/ui/src/components/shared/stat-card.tsx @@ -0,0 +1,88 @@ +import { Card, CardContent } from '@/components/ui/card'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface StatCardProps { + title: string; + value: number | string; + icon: LucideIcon; + color?: string; + variant?: 'default' | 'success' | 'warning' | 'error' | 'accent'; + subtitle?: string; + onClick?: () => void; +} + +const variantStyles = { + default: { + iconBg: 'bg-muted', + iconColor: 'text-muted-foreground', + borderHover: 'hover:border-primary', + }, + success: { + iconBg: 'bg-green-500/10', + iconColor: 'text-green-600', + borderHover: 'hover:border-green-500/50', + }, + warning: { + iconBg: 'bg-yellow-500/10', + iconColor: 'text-yellow-500', + borderHover: 'hover:border-yellow-500/50', + }, + error: { + iconBg: 'bg-red-500/10', + iconColor: 'text-red-500', + borderHover: 'hover:border-red-500/50', + }, + accent: { + iconBg: 'bg-accent/10', + iconColor: 'text-accent', + borderHover: 'hover:border-accent/50', + }, +}; + +export function StatCard({ + title, + value, + icon: Icon, + color, + variant = 'default', + subtitle, + onClick, +}: StatCardProps) { + const styles = variantStyles[variant]; + const iconColorClass = color || styles.iconColor; + + return ( + + +
+ {/* Icon Container with background */} +
+ +
+ + {/* Content */} +
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+
+
+ ); +} diff --git a/ui/src/components/shared/value-metrics.tsx b/ui/src/components/shared/value-metrics.tsx new file mode 100644 index 00000000..9975a7b9 --- /dev/null +++ b/ui/src/components/shared/value-metrics.tsx @@ -0,0 +1,114 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { TrendingUpIcon, TrendingDownIcon, DollarSignIcon, ZapIcon } from 'lucide-react'; + +interface MetricCardProps { + title: string; + value: string | number; + change?: number; + changeLabel?: string; + icon: React.ReactNode; + trend?: 'up' | 'down' | 'neutral'; +} + +function MetricCard({ title, value, change, changeLabel, icon, trend }: MetricCardProps) { + const TrendIcon = trend === 'up' ? TrendingUpIcon : trend === 'down' ? TrendingDownIcon : null; + + return ( + + +
+
+ {icon} +

{title}

+
+ {trend && TrendIcon && ( + + + {change && `${Math.abs(change)}%`} + + )} +
+
+
{value}
+ {changeLabel &&
{changeLabel}
} +
+
+
+ ); +} + +export function ValueMetrics() { + // Mock data for demonstration + const metrics = [ + { + title: 'API Cost Saved', + value: '$127.50', + change: 23, + changeLabel: 'vs last month', + icon: , + trend: 'up' as const, + }, + { + title: 'Tokens Saved', + value: '2.4M', + change: 18, + changeLabel: 'through caching', + icon: , + trend: 'up' as const, + }, + { + title: 'Queries Faster', + value: '43%', + change: 12, + changeLabel: 'average speedup', + icon: , + trend: 'up' as const, + }, + { + title: 'Errors Reduced', + value: '-67%', + change: 67, + changeLabel: 'with retry logic', + icon: , + trend: 'down' as const, + }, + ]; + + return ( +
+

Performance Metrics

+
+ {metrics.map((metric, index) => ( + + ))} +
+ + + + Monthly Summary + + +
+
+
$342.10
+
Total Saved
+
+
+
8.7M
+
Tokens Processed
+
+
+
1,247
+
Queries Handled
+
+
+
99.8%
+
Uptime
+
+
+
+
+
+ ); +}