improvement(settings): added sections to settings and improved layout

This commit is contained in:
Emir Karabeg
2025-02-16 01:31:05 -08:00
parent dc85adc444
commit cef91e14d0
5 changed files with 384 additions and 287 deletions

View File

@@ -0,0 +1,268 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEnvironmentStore } from '@/stores/environment/store'
import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/environment/types'
// Extend the store type with our UI-specific fields
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
id?: number
}
interface EnvironmentVariablesProps {
onOpenChange: (open: boolean) => void
}
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) {
const { variables, setVariable, removeVariable } = useEnvironmentStore()
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
// Check if there are unsaved changes by comparing with initial state
const hasChanges = useMemo(() => {
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
const currentVars = envVars.filter((v) => v.key || v.value)
const initialMap = new Map(initialVars.map((v) => [v.key, v.value]))
const currentMap = new Map(currentVars.map((v) => [v.key, v.value]))
if (initialMap.size !== currentMap.size) return true
for (const [key, value] of currentMap) {
const initialValue = initialMap.get(key)
if (initialValue !== value) return true
}
for (const key of initialMap.keys()) {
if (!currentMap.has(key)) return true
}
return false
}, [envVars])
// Initialize environment variables
useEffect(() => {
const existingVars = Object.values(variables)
const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR]
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
pendingClose.current = false
}, [variables])
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setShowUnsavedChanges(false)
if (pendingClose.current) {
onOpenChange(false)
}
}
useEffect(() => {
if (scrollContainerRef.current) {
// Smooth scroll to bottom when new variables are added
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
}
}, [envVars.length]) // Only trigger on length changes
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
e.target.scrollLeft = 0
}
const handleValueClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
e.currentTarget.scrollLeft = 0
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
const text = e.clipboardData.getData('text').trim()
if (!text) return
const lines = text.split('\n').filter((line) => line.trim())
if (lines.length === 0) return
e.preventDefault()
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
| 'key'
| 'value'
const containsKeyValuePair = text.includes('=')
if (inputType && !containsKeyValuePair) {
handleSingleValuePaste(text, index, inputType)
return
}
handleKeyValuePaste(lines)
}
const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => {
const newEnvVars = [...envVars]
newEnvVars[index][inputType] = text
setEnvVars(newEnvVars)
}
const handleKeyValuePaste = (lines: string[]) => {
const parsedVars = lines
.map((line) => {
const [key, ...valueParts] = line.split('=')
const value = valueParts.join('=').trim()
return {
key: key.trim(),
value,
id: Date.now() + Math.random(),
}
})
.filter(({ key, value }) => key && value)
if (parsedVars.length > 0) {
const existingVars = envVars.filter((v) => v.key || v.value)
setEnvVars([...existingVars, ...parsedVars])
}
}
const addEnvVar = () => {
const newVar = { key: '', value: '', id: Date.now() }
setEnvVars([...envVars, newVar])
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const removeEnvVar = (index: number) => {
const newEnvVars = envVars.filter((_, i) => i !== index)
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
}
const handleSave = () => {
const validVars = envVars.filter((v) => v.key && v.value)
validVars.forEach((v) => setVariable(v.key, v.value))
const currentKeys = new Set(validVars.map((v) => v.key))
Object.keys(variables).forEach((key) => {
if (!currentKeys.has(key)) {
removeVariable(key)
}
})
setShowUnsavedChanges(false)
onOpenChange(false)
}
const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => (
<div key={envVar.id || index} className={`${GRID_COLS} items-center`}>
<Input
data-input-type="key"
value={envVar.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
onPaste={(e) => handlePaste(e, index)}
placeholder="e.g. API_KEY"
/>
<Input
data-input-type="value"
value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
type={focusedValueIndex === index ? 'text' : 'password'}
onFocus={(e) => handleValueFocus(index, e)}
onClick={handleValueClick}
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, index)}
placeholder="Enter value"
className="allow-scroll"
/>
<Button variant="ghost" size="icon" onClick={() => removeEnvVar(index)} className="h-10 w-10">
×
</Button>
</div>
)
return (
<div className="flex flex-col h-full">
{/* Fixed Header */}
<div className="px-6 pt-6">
<h2 className="text-lg font-medium mb-4">Environment Variables</h2>
<div className={`${GRID_COLS} px-0.5 mb-2`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollContainerRef}
className="flex-1 px-6 overflow-y-auto min-h-0 scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent"
>
<div className="space-y-2 py-2">{envVars.map(renderEnvVarRow)}</div>
</div>
{/* Fixed Footer */}
<div className="px-6 pb-6 pt-4 border-t mt-auto">
<div className="flex flex-col gap-4">
<Button variant="outline" size="sm" onClick={addEnvVar}>
Add Variable
</Button>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanges}>
Save Changes
</Button>
</div>
</div>
</div>
<AlertDialog open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Do you want to save them before closing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>Discard Changes</AlertDialogCancel>
<AlertDialogAction onClick={handleSave}>Save Changes</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
export function General() {
return (
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-medium mb-4">General Settings</h2>
<div className="space-y-4">
<div className="flex items-center justify-between py-1">
<Label htmlFor="debug-mode" className="font-medium">
Debug Mode
</Label>
<Switch id="debug-mode" />
</div>
<div className="flex items-center justify-between py-1">
<Label htmlFor="auto-save" className="font-medium">
Auto-save Workflows
</Label>
<Switch id="auto-save" />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { KeyRound, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SettingsNavigationProps {
activeSection: string
onSectionChange: (section: 'general' | 'environment') => void
}
const navigationItems = [
{
id: 'general',
label: 'General',
icon: Settings,
},
{
id: 'environment',
label: 'Environment',
icon: KeyRound,
},
] as const
export function SettingsNavigation({ activeSection, onSectionChange }: SettingsNavigationProps) {
return (
<div className="py-4">
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors',
'hover:bg-accent/50',
activeSection === item.id
? 'bg-accent/50 text-foreground font-medium'
: 'text-muted-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
))}
</div>
)
}

View File

@@ -1,301 +1,59 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEnvironmentStore } from '@/stores/environment/store'
import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/environment/types'
// Extend the store type with our UI-specific fields
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
id?: number
}
import { cn } from '@/lib/utils'
import { EnvironmentVariables } from './components/environment-variables/environment-variables'
import { General } from './components/general/general'
import { SettingsNavigation } from './components/settings-navigation/settings-navigation'
interface SettingsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
type SettingsSection = 'general' | 'environment'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const { variables, setVariable, removeVariable } = useEnvironmentStore()
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
// Check if there are unsaved changes by comparing with initial state
const hasChanges = useMemo(() => {
// Filter out empty rows from both initial and current state
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
const currentVars = envVars.filter((v) => v.key || v.value)
// Create maps for easier comparison
const initialMap = new Map(initialVars.map((v) => [v.key, v.value]))
const currentMap = new Map(currentVars.map((v) => [v.key, v.value]))
// Different number of non-empty variables
if (initialMap.size !== currentMap.size) return true
// Check for any differences in keys or values
for (const [key, value] of currentMap) {
const initialValue = initialMap.get(key)
// If key doesn't exist in initial or value is different
if (initialValue !== value) {
return true
}
}
// Check if any initial keys are missing from current
for (const key of initialMap.keys()) {
if (!currentMap.has(key)) {
return true
}
}
return false
}, [envVars])
// Reset state when modal is opened
useEffect(() => {
if (open) {
const existingVars = Object.values(variables)
const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR]
// Create deep copy of initial vars to prevent reference issues
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
pendingClose.current = false
}
}, [open, variables])
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
// Reset to initial state when cancelling (without saving)
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setShowUnsavedChanges(false)
if (pendingClose.current) {
onOpenChange(false)
}
}
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight
}
}, [envVars])
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
// Always scroll to the start of the input
e.target.scrollLeft = 0
}
const handleValueClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
// Always scroll to the start of the input
e.currentTarget.scrollLeft = 0
}
const handleValueKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
const text = e.clipboardData.getData('text').trim()
if (!text) return
const lines = text.split('\n').filter((line) => line.trim())
if (lines.length === 0) return
e.preventDefault()
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
| 'key'
| 'value'
const containsKeyValuePair = text.includes('=')
// Handle single value paste into specific field
if (inputType && !containsKeyValuePair) {
handleSingleValuePaste(text, index, inputType)
return
}
// Handle key-value pair(s) paste
handleKeyValuePaste(lines)
}
const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => {
const newEnvVars = [...envVars]
newEnvVars[index][inputType] = text
setEnvVars(newEnvVars)
}
const handleKeyValuePaste = (lines: string[]) => {
const parsedVars = lines
.map((line) => {
const [key, ...valueParts] = line.split('=')
const value = valueParts.join('=').trim()
return {
key: key.trim(),
value,
id: Date.now() + Math.random(),
}
})
.filter(({ key, value }) => key && value)
if (parsedVars.length > 0) {
// Merge existing vars with new ones, removing any empty rows at the end
const existingVars = envVars.filter((v) => v.key || v.value)
setEnvVars([...existingVars, ...parsedVars])
}
}
const addEnvVar = () => {
// Create a fresh empty variable with a unique id
const newVar = { key: '', value: '', id: Date.now() }
setEnvVars([...envVars, newVar])
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const removeEnvVar = (index: number) => {
const newEnvVars = envVars.filter((_, i) => i !== index)
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
}
const handleSave = () => {
// Save all valid environment variables to the store
const validVars = envVars.filter((v) => v.key && v.value)
validVars.forEach((v) => setVariable(v.key, v.value))
// Remove any variables that were deleted
const currentKeys = new Set(validVars.map((v) => v.key))
Object.keys(variables).forEach((key) => {
if (!currentKeys.has(key)) {
removeVariable(key)
}
})
// Close both the alert dialog and the main modal
setShowUnsavedChanges(false)
onOpenChange(false)
}
const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => (
<div key={envVar.id || index} className={`${GRID_COLS} items-center`}>
<Input
data-input-type="key"
value={envVar.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
onPaste={(e) => handlePaste(e, index)}
placeholder="e.g. API_KEY"
/>
<Input
data-input-type="value"
value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
type={focusedValueIndex === index ? 'text' : 'password'}
onFocus={(e) => handleValueFocus(index, e)}
onClick={handleValueClick}
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, index)}
placeholder="Enter value"
className="allow-scroll"
/>
<Button variant="ghost" size="icon" onClick={() => removeEnvVar(index)} className="h-10 w-10">
×
</Button>
</div>
)
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Environment Variables</DialogTitle>
</DialogHeader>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[660px] h-[60vh] flex flex-col p-0 gap-0" hideCloseButton>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">Settings</DialogTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</DialogHeader>
<div className="flex flex-col flex-1 min-h-0">
<div className="space-y-1.5">
<div className={`${GRID_COLS} px-0.5`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
</div>
<div className="flex flex-1 min-h-0">
{/* Navigation Sidebar */}
<div className="w-[200px] border-r">
<SettingsNavigation activeSection={activeSection} onSectionChange={setActiveSection} />
</div>
<div className="relative">
<div
ref={scrollContainerRef}
className="overflow-y-auto max-h-[40vh] space-y-2 scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent pr-6 -mr-6 pb-2 pt-2 px-2 -mx-2"
>
{envVars.map(renderEnvVarRow)}
</div>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto">
<div className={cn('h-full', activeSection === 'general' ? 'block' : 'hidden')}>
<General />
</div>
<div className="flex flex-col gap-4 pt-4 border-t mt-4">
<Button variant="outline" size="sm" onClick={addEnvVar}>
Add Variable
</Button>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanges}>
Save Changes
</Button>
</div>
<div className={cn('h-full', activeSection === 'environment' ? 'block' : 'hidden')}>
<EnvironmentVariables onOpenChange={onOpenChange} />
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Do you want to save them before closing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>Discard Changes</AlertDialogCancel>
<AlertDialogAction onClick={handleSave}>Save Changes</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@@ -30,8 +30,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -43,10 +43,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))