mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 19:03:09 +00:00
basic ui
This commit is contained in:
213
ui/src/components/AddFeatureForm.tsx
Normal file
213
ui/src/components/AddFeatureForm.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useId } from 'react'
|
||||
import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useCreateFeature } from '../hooks/useProjects'
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AddFeatureFormProps {
|
||||
projectName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) {
|
||||
const formId = useId()
|
||||
const [category, setCategory] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [steps, setSteps] = useState<Step[]>([{ id: `${formId}-step-0`, value: '' }])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stepCounter, setStepCounter] = useState(1)
|
||||
|
||||
const createFeature = useCreateFeature(projectName)
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }])
|
||||
setStepCounter(stepCounter + 1)
|
||||
}
|
||||
|
||||
const handleRemoveStep = (id: string) => {
|
||||
setSteps(steps.filter(step => step.id !== id))
|
||||
}
|
||||
|
||||
const handleStepChange = (id: string, value: string) => {
|
||||
setSteps(steps.map(step =>
|
||||
step.id === id ? { ...step, value } : step
|
||||
))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Filter out empty steps
|
||||
const filteredSteps = steps
|
||||
.map(s => s.value.trim())
|
||||
.filter(s => s.length > 0)
|
||||
|
||||
try {
|
||||
await createFeature.mutateAsync({
|
||||
category: category.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
steps: filteredSteps,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create feature')
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = category.trim() && name.trim() && description.trim()
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
Add Feature
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Authentication, UI, API"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Feature Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., User login form"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this feature should do..."
|
||||
className="neo-input min-h-[100px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps (Optional)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex gap-2">
|
||||
<span className="neo-input w-12 text-center flex-shrink-0 flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={step.value}
|
||||
onChange={(e) => handleStepChange(step.id, e.target.value)}
|
||||
placeholder="Describe this step..."
|
||||
className="neo-input flex-1"
|
||||
/>
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveStep(step.id)}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddStep}
|
||||
className="neo-btn neo-btn-ghost mt-2 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || createFeature.isPending}
|
||||
className="neo-btn neo-btn-success flex-1"
|
||||
>
|
||||
{createFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus size={18} />
|
||||
Create Feature
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
ui/src/components/AgentControl.tsx
Normal file
144
ui/src/components/AgentControl.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Play, Pause, Square, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
useStartAgent,
|
||||
useStopAgent,
|
||||
usePauseAgent,
|
||||
useResumeAgent,
|
||||
} from '../hooks/useProjects'
|
||||
import type { AgentStatus } from '../lib/types'
|
||||
|
||||
interface AgentControlProps {
|
||||
projectName: string
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
const startAgent = useStartAgent(projectName)
|
||||
const stopAgent = useStopAgent(projectName)
|
||||
const pauseAgent = usePauseAgent(projectName)
|
||||
const resumeAgent = useResumeAgent(projectName)
|
||||
|
||||
const isLoading =
|
||||
startAgent.isPending ||
|
||||
stopAgent.isPending ||
|
||||
pauseAgent.isPending ||
|
||||
resumeAgent.isPending
|
||||
|
||||
const handleStart = () => startAgent.mutate()
|
||||
const handleStop = () => stopAgent.mutate()
|
||||
const handlePause = () => pauseAgent.mutate()
|
||||
const handleResume = () => resumeAgent.mutate()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Indicator */}
|
||||
<StatusIndicator status={status} />
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-1">
|
||||
{status === 'stopped' || status === 'crashed' ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
||||
title="Start Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
) : status === 'running' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-warning text-sm py-2 px-3"
|
||||
title="Pause Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Pause size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
||||
title="Stop Agent"
|
||||
>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : status === 'paused' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
||||
title="Resume Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
||||
title="Stop Agent"
|
||||
>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: AgentStatus }) {
|
||||
const statusConfig = {
|
||||
stopped: {
|
||||
color: 'var(--color-neo-text-secondary)',
|
||||
label: 'Stopped',
|
||||
pulse: false,
|
||||
},
|
||||
running: {
|
||||
color: 'var(--color-neo-done)',
|
||||
label: 'Running',
|
||||
pulse: true,
|
||||
},
|
||||
paused: {
|
||||
color: 'var(--color-neo-pending)',
|
||||
label: 'Paused',
|
||||
pulse: false,
|
||||
},
|
||||
crashed: {
|
||||
color: 'var(--color-neo-danger)',
|
||||
label: 'Crashed',
|
||||
pulse: true,
|
||||
},
|
||||
}
|
||||
|
||||
const config = statusConfig[status]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white border-3 border-[var(--color-neo-border)]">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
|
||||
style={{ backgroundColor: config.color }}
|
||||
/>
|
||||
<span
|
||||
className="font-display font-bold text-sm uppercase"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
ui/src/components/FeatureCard.tsx
Normal file
86
ui/src/components/FeatureCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: Feature
|
||||
onClick: () => void
|
||||
isInProgress?: boolean
|
||||
}
|
||||
|
||||
// Generate consistent color for category
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors = [
|
||||
'#ff006e', // pink
|
||||
'#00b4d8', // cyan
|
||||
'#70e000', // green
|
||||
'#ffd60a', // yellow
|
||||
'#ff5400', // orange
|
||||
'#8338ec', // purple
|
||||
'#3a86ff', // blue
|
||||
]
|
||||
|
||||
let hash = 0
|
||||
for (let i = 0; i < category.length; i++) {
|
||||
hash = category.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
|
||||
const categoryColor = getCategoryColor(feature.category)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full text-left neo-card p-4 cursor-pointer
|
||||
${isInProgress ? 'animate-pulse-neo' : ''}
|
||||
${feature.passes ? 'border-[var(--color-neo-done)]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span
|
||||
className="neo-badge"
|
||||
style={{ backgroundColor: categoryColor, color: 'white' }}
|
||||
>
|
||||
{feature.category}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-[var(--color-neo-text-secondary)]">
|
||||
#{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="font-display font-bold mb-1 line-clamp-2">
|
||||
{feature.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)] line-clamp-2 mb-3">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isInProgress ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
<span className="text-[var(--color-neo-progress)] font-bold">Processing...</span>
|
||||
</>
|
||||
) : feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-[var(--color-neo-done)] font-bold">Complete</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="text-[var(--color-neo-text-secondary)]">Pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
190
ui/src/components/FeatureModal.tsx
Normal file
190
ui/src/components/FeatureModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureModalProps {
|
||||
feature: Feature
|
||||
projectName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const skipFeature = useSkipFeature(projectName)
|
||||
const deleteFeature = useDeleteFeature(projectName)
|
||||
|
||||
const handleSkip = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await skipFeature.mutateAsync(feature.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to skip feature')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await deleteFeature.mutateAsync(feature.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete feature')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<div>
|
||||
<span className="neo-badge bg-[var(--color-neo-accent)] text-white mb-2">
|
||||
{feature.category}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
{feature.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-done)]">
|
||||
COMPLETE
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={24} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-text-secondary)]">
|
||||
PENDING
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-sm">
|
||||
Priority: #{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-[var(--color-neo-text-secondary)]">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{feature.steps.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{feature.steps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="p-3 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]"
|
||||
>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!feature.passes && (
|
||||
<div className="p-6 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="space-y-4">
|
||||
<p className="font-bold text-center">
|
||||
Are you sure you want to delete this feature?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-danger flex-1"
|
||||
>
|
||||
{deleteFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
'Yes, Delete'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-ghost flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-warning flex-1"
|
||||
>
|
||||
{skipFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<SkipForward size={18} />
|
||||
Skip (Move to End)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-danger"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
ui/src/components/KanbanBoard.tsx
Normal file
52
ui/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { KanbanColumn } from './KanbanColumn'
|
||||
import type { Feature, FeatureListResponse } from '../lib/types'
|
||||
|
||||
interface KanbanBoardProps {
|
||||
features: FeatureListResponse | undefined
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({ features, onFeatureClick }: KanbanBoardProps) {
|
||||
if (!features) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{['Pending', 'In Progress', 'Done'].map(title => (
|
||||
<div key={title} className="neo-card p-4">
|
||||
<div className="h-8 bg-[var(--color-neo-bg)] animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-[var(--color-neo-bg)] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
count={features.pending.length}
|
||||
features={features.pending}
|
||||
color="pending"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="In Progress"
|
||||
count={features.in_progress.length}
|
||||
features={features.in_progress}
|
||||
color="progress"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
count={features.done.length}
|
||||
features={features.done}
|
||||
color="done"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
ui/src/components/KanbanColumn.tsx
Normal file
65
ui/src/components/KanbanColumn.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FeatureCard } from './FeatureCard'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string
|
||||
count: number
|
||||
features: Feature[]
|
||||
color: 'pending' | 'progress' | 'done'
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
pending: 'var(--color-neo-pending)',
|
||||
progress: 'var(--color-neo-progress)',
|
||||
done: 'var(--color-neo-done)',
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
title,
|
||||
count,
|
||||
features,
|
||||
color,
|
||||
onFeatureClick,
|
||||
}: KanbanColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className="neo-card overflow-hidden"
|
||||
style={{ borderColor: colorMap[color] }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b-3 border-[var(--color-neo-border)]"
|
||||
style={{ backgroundColor: colorMap[color] }}
|
||||
>
|
||||
<h2 className="font-display text-lg font-bold uppercase flex items-center justify-between text-[var(--color-neo-text)]">
|
||||
{title}
|
||||
<span className="neo-badge bg-white text-[var(--color-neo-text)]">{count}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]">
|
||||
{features.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]">
|
||||
No features
|
||||
</div>
|
||||
) : (
|
||||
features.map((feature, index) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="animate-slide-in"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<FeatureCard
|
||||
feature={feature}
|
||||
onClick={() => onFeatureClick(feature)}
|
||||
isInProgress={color === 'progress'}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
ui/src/components/ProgressDashboard.tsx
Normal file
77
ui/src/components/ProgressDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface ProgressDashboardProps {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function ProgressDashboard({
|
||||
passing,
|
||||
total,
|
||||
percentage,
|
||||
isConnected,
|
||||
}: ProgressDashboardProps) {
|
||||
return (
|
||||
<div className="neo-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-xl font-bold uppercase">
|
||||
Progress
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-sm text-[var(--color-neo-done)]">Live</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} className="text-[var(--color-neo-danger)]" />
|
||||
<span className="text-sm text-[var(--color-neo-danger)]">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Percentage */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="font-display text-6xl font-bold">
|
||||
{percentage.toFixed(1)}
|
||||
</span>
|
||||
<span className="font-display text-3xl font-bold text-[var(--color-neo-text-secondary)]">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="neo-progress mb-4">
|
||||
<div
|
||||
className="neo-progress-fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-8 text-center">
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold text-[var(--color-neo-done)]">
|
||||
{passing}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Passing
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-4xl text-[var(--color-neo-text-secondary)]">/</div>
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold">
|
||||
{total}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
ui/src/components/ProjectSelector.tsx
Normal file
172
ui/src/components/ProjectSelector.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react'
|
||||
import { useCreateProject } from '../hooks/useProjects'
|
||||
import type { ProjectSummary } from '../lib/types'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: ProjectSummary[]
|
||||
selectedProject: string | null
|
||||
onSelectProject: (name: string | null) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
isLoading,
|
||||
}: ProjectSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
const createProject = useCreateProject()
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newProjectName.trim()) return
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
specMethod: 'manual',
|
||||
})
|
||||
onSelectProject(project.name)
|
||||
setNewProjectName('')
|
||||
setShowCreate(false)
|
||||
setIsOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProjectData = projects.find(p => p.name === selectedProject)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Dropdown Trigger */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="neo-btn bg-white text-[var(--color-neo-text)] min-w-[200px] justify-between"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : selectedProject ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={18} />
|
||||
{selectedProject}
|
||||
</span>
|
||||
{selectedProjectData && selectedProjectData.stats.total > 0 && (
|
||||
<span className="neo-badge bg-[var(--color-neo-done)] ml-2">
|
||||
{selectedProjectData.stats.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--color-neo-text-secondary)]">
|
||||
Select Project
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-2 w-full neo-dropdown z-50 min-w-[280px]">
|
||||
{projects.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
{projects.map(project => (
|
||||
<button
|
||||
key={project.name}
|
||||
onClick={() => {
|
||||
onSelectProject(project.name)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full neo-dropdown-item flex items-center justify-between ${
|
||||
project.name === selectedProject
|
||||
? 'bg-[var(--color-neo-pending)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
{project.name}
|
||||
</span>
|
||||
{project.stats.total > 0 && (
|
||||
<span className="text-sm font-mono">
|
||||
{project.stats.passing}/{project.stats.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
|
||||
No projects yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t-3 border-[var(--color-neo-border)]" />
|
||||
|
||||
{/* Create New */}
|
||||
{showCreate ? (
|
||||
<form onSubmit={handleCreateProject} className="p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="project-name"
|
||||
className="neo-input text-sm mb-2"
|
||||
pattern="^[a-zA-Z0-9_-]+$"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="neo-btn neo-btn-success text-xs flex-1"
|
||||
disabled={createProject.isPending || !newProjectName.trim()}
|
||||
>
|
||||
{createProject.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreate(false)
|
||||
setNewProjectName('')
|
||||
}}
|
||||
className="neo-btn neo-btn-ghost text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full neo-dropdown-item flex items-center gap-2 font-bold"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
ui/src/components/SetupWizard.tsx
Normal file
183
ui/src/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useSetupStatus, useHealthCheck } from '../hooks/useProjects'
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function SetupWizard({ onComplete }: SetupWizardProps) {
|
||||
const { data: setupStatus, isLoading: setupLoading, error: setupError } = useSetupStatus()
|
||||
const { data: health, error: healthError } = useHealthCheck()
|
||||
|
||||
const isApiHealthy = health?.status === 'healthy' && !healthError
|
||||
const isReady = isApiHealthy && setupStatus?.claude_cli && setupStatus?.credentials
|
||||
|
||||
// Memoize the completion check to avoid infinite loops
|
||||
const checkAndComplete = useCallback(() => {
|
||||
if (isReady) {
|
||||
onComplete()
|
||||
}
|
||||
}, [isReady, onComplete])
|
||||
|
||||
// Auto-complete if everything is ready
|
||||
useEffect(() => {
|
||||
checkAndComplete()
|
||||
}, [checkAndComplete])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-neo-bg)] flex items-center justify-center p-4">
|
||||
<div className="neo-card w-full max-w-lg p-8">
|
||||
<h1 className="font-display text-3xl font-bold text-center mb-2">
|
||||
Setup Wizard
|
||||
</h1>
|
||||
<p className="text-center text-[var(--color-neo-text-secondary)] mb-8">
|
||||
Let's make sure everything is ready to go
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* API Health */}
|
||||
<SetupItem
|
||||
label="Backend Server"
|
||||
description="FastAPI server is running"
|
||||
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
|
||||
/>
|
||||
|
||||
{/* Claude CLI */}
|
||||
<SetupItem
|
||||
label="Claude CLI"
|
||||
description="Claude Code CLI is installed"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.claude_cli
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://docs.anthropic.com/claude/claude-code"
|
||||
helpText="Install Claude Code"
|
||||
/>
|
||||
|
||||
{/* Credentials */}
|
||||
<SetupItem
|
||||
label="Anthropic Credentials"
|
||||
description="API credentials are configured"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.credentials
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://console.anthropic.com/account/keys"
|
||||
helpText="Get API Key"
|
||||
/>
|
||||
|
||||
{/* Node.js */}
|
||||
<SetupItem
|
||||
label="Node.js"
|
||||
description="Node.js is installed (for UI dev)"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.node
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
helpLink="https://nodejs.org"
|
||||
helpText="Install Node.js"
|
||||
optional
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
{isReady && (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="neo-btn neo-btn-success w-full mt-8"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{(healthError || setupError) && (
|
||||
<div className="mt-6 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<p className="font-bold mb-2">Setup Error</p>
|
||||
<p className="text-sm">
|
||||
{healthError
|
||||
? 'Cannot connect to the backend server. Make sure to run start_ui.py first.'
|
||||
: 'Failed to check setup status.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SetupItemProps {
|
||||
label: string
|
||||
description: string
|
||||
status: 'success' | 'error' | 'warning' | 'loading'
|
||||
helpLink?: string
|
||||
helpText?: string
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
function SetupItem({
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
helpLink,
|
||||
helpText,
|
||||
optional,
|
||||
}: SetupItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{status === 'success' ? (
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
) : status === 'error' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-danger)]" />
|
||||
) : status === 'warning' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-pending)]" />
|
||||
) : (
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-display font-bold">{label}</span>
|
||||
{optional && (
|
||||
<span className="text-xs text-[var(--color-neo-text-secondary)]">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
{(status === 'error' || status === 'warning') && helpLink && (
|
||||
<a
|
||||
href={helpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-[var(--color-neo-accent)] hover:underline"
|
||||
>
|
||||
{helpText} <ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user