mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-21 12:53:09 +00:00
refactor(ui): migrate to shadcn/ui components and fix scroll issues
Migrate UI component library from custom implementations to shadcn/ui: - Add shadcn/ui primitives (Button, Card, Dialog, Input, etc.) - Replace custom styles with Tailwind CSS v4 theme configuration - Remove custom-theme.css in favor of globals.css with @theme directive Fix scroll overflow issues in multiple components: - ProjectSelector: "New Project" button no longer overlays project list - FolderBrowser: folder list now scrolls properly within modal - AgentCard: log modal content stays within bounds - ConversationHistory: conversation list scrolls correctly - KanbanColumn: feature cards scroll within fixed height - ScheduleModal: schedule form content scrolls properly Key technical changes: - Replace ScrollArea component with native overflow-y-auto divs - Add min-h-0 to flex containers to allow proper shrinking - Restructure dropdown layouts with flex-col for fixed footers New files: - ui/components.json (shadcn/ui configuration) - ui/src/components/ui/* (20 UI primitive components) - ui/src/lib/utils.ts (cn utility for class merging) - ui/tsconfig.app.json (app-specific TypeScript config) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||
const { data: modelsData } = useAvailableModels()
|
||||
const updateSettings = useUpdateSettings()
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Focus trap - keep focus within modal
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current
|
||||
if (!modal) return
|
||||
|
||||
// Focus the close button when modal opens
|
||||
closeButtonRef.current?.focus()
|
||||
|
||||
const focusableElements = modal.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleTabKey)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleTabKey)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleYoloToggle = () => {
|
||||
if (settings && !updateSettings.isPending) {
|
||||
@@ -80,36 +43,14 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const isSaving = updateSettings.isPending
|
||||
|
||||
return (
|
||||
<div
|
||||
className="neo-modal-backdrop"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="neo-modal w-full max-w-sm p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 id="settings-title" className="font-display text-xl font-bold">
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Settings
|
||||
{isSaving && (
|
||||
<Loader2 className="inline-block ml-2 animate-spin" size={16} />
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{isSaving && <Loader2 className="animate-spin" size={16} />}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
@@ -121,82 +62,55 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
|
||||
{/* Error State */}
|
||||
{isError && (
|
||||
<div className="p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)] mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={18} />
|
||||
<span>Failed to load settings</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 underline text-sm hover:opacity-70 transition-opacity"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load settings
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => refetch()}
|
||||
className="ml-2 p-0 h-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Settings Content */}
|
||||
{settings && !isLoading && (
|
||||
<div className="space-y-6">
|
||||
{/* YOLO Mode Toggle */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label
|
||||
id="yolo-label"
|
||||
className="font-display font-bold text-base"
|
||||
>
|
||||
YOLO Mode
|
||||
</label>
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
|
||||
Skip testing for rapid prototyping
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleYoloToggle}
|
||||
disabled={isSaving}
|
||||
className={`relative w-14 h-8 rounded-none border-3 border-[var(--color-neo-border)] transition-colors ${
|
||||
settings.yolo_mode
|
||||
? 'bg-[var(--color-neo-pending)]'
|
||||
: 'bg-[var(--color-neo-card)]'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={settings.yolo_mode}
|
||||
aria-labelledby="yolo-label"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-5 h-5 bg-[var(--color-neo-border)] transition-transform ${
|
||||
settings.yolo_mode ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="yolo-mode" className="font-medium">
|
||||
YOLO Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skip testing for rapid prototyping
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="yolo-mode"
|
||||
checked={settings.yolo_mode}
|
||||
onCheckedChange={handleYoloToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Radio Group */}
|
||||
<div>
|
||||
<label
|
||||
id="model-label"
|
||||
className="font-display font-bold text-base block mb-2"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
className="flex border-3 border-[var(--color-neo-border)]"
|
||||
role="radiogroup"
|
||||
aria-labelledby="model-label"
|
||||
>
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Model</Label>
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
disabled={isSaving}
|
||||
role="radio"
|
||||
aria-checked={settings.model === model.id}
|
||||
className={`flex-1 py-3 px-4 font-display font-bold text-sm transition-colors ${
|
||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||
settings.model === model.id
|
||||
? 'bg-[var(--color-neo-accent)] text-[var(--color-neo-text-on-bright)]'
|
||||
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-foreground hover:bg-muted'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{model.name}
|
||||
@@ -206,32 +120,21 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
|
||||
{/* Regression Agents */}
|
||||
<div>
|
||||
<label
|
||||
id="testing-ratio-label"
|
||||
className="font-display font-bold text-base block mb-1"
|
||||
>
|
||||
Regression Agents
|
||||
</label>
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)] mb-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Regression Agents</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Number of regression testing agents (0 = disabled)
|
||||
</p>
|
||||
<div
|
||||
className="flex border-3 border-[var(--color-neo-border)]"
|
||||
role="radiogroup"
|
||||
aria-labelledby="testing-ratio-label"
|
||||
>
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
{[0, 1, 2, 3].map((ratio) => (
|
||||
<button
|
||||
key={ratio}
|
||||
onClick={() => handleTestingRatioChange(ratio)}
|
||||
disabled={isSaving}
|
||||
role="radio"
|
||||
aria-checked={settings.testing_agent_ratio === ratio}
|
||||
className={`flex-1 py-2 px-3 font-display font-bold text-sm transition-colors ${
|
||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||
settings.testing_agent_ratio === ratio
|
||||
? 'bg-[var(--color-neo-progress)] text-[var(--color-neo-text)]'
|
||||
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-foreground hover:bg-muted'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{ratio}
|
||||
@@ -242,13 +145,15 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
|
||||
{/* Update Error */}
|
||||
{updateSettings.isError && (
|
||||
<div className="p-3 bg-[var(--color-neo-error-bg)] border-3 border-[var(--color-neo-error-border)] text-[var(--color-neo-error-text)] text-sm">
|
||||
Failed to save settings. Please try again.
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Failed to save settings. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user