Pipeline UI/UX redesign: inline editing, flowchart, sidebar stepper

- Add InlineEditableText, EditableCard, SidebarStepper shared components
- Add PipelineFlowchart (interactive SVG stage visualization)
- Add StageConfigEditor and usePipelineInlineEdit hook
- Redesign detail page: flowchart replaces nested tabs, inline editing
- Redesign creation wizard: sidebar stepper replaces accordion sections
- Enhance list page: status dots, track indicators, relative timestamps
- Convert edit page to redirect (editing now inline on detail page)
- Delete old WizardSection accordion component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 01:54:56 +01:00
parent 70cfad7d46
commit 59f90ccc37
11 changed files with 1609 additions and 935 deletions

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Pencil, Check, X } from 'lucide-react'
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
type InlineEditableTextProps = {
value: string
onSave: (newValue: string) => void | Promise<void>
variant?: InlineEditableTextVariant
placeholder?: string
multiline?: boolean
disabled?: boolean
className?: string
}
const variantStyles: Record<InlineEditableTextVariant, string> = {
h1: 'text-xl font-bold',
h2: 'text-base font-semibold',
body: 'text-sm',
mono: 'text-sm font-mono text-muted-foreground',
}
export function InlineEditableText({
value,
onSave,
variant = 'body',
placeholder = 'Click to edit...',
multiline = false,
disabled = false,
className,
}: InlineEditableTextProps) {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
useEffect(() => {
setEditValue(value)
}, [value])
const handleSave = useCallback(async () => {
const trimmed = editValue.trim()
if (trimmed === value) {
setIsEditing(false)
return
}
if (!trimmed) {
setEditValue(value)
setIsEditing(false)
return
}
setIsSaving(true)
try {
await onSave(trimmed)
setIsEditing(false)
} catch {
setEditValue(value)
} finally {
setIsSaving(false)
}
}, [editValue, value, onSave])
const handleCancel = useCallback(() => {
setEditValue(value)
setIsEditing(false)
}, [value])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel()
}
if (e.key === 'Enter') {
if (multiline && !e.ctrlKey) return
e.preventDefault()
handleSave()
}
},
[handleCancel, handleSave, multiline]
)
if (disabled) {
return (
<span className={cn(variantStyles[variant], className)}>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
)
}
return (
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="editing"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={cn('flex items-center gap-1.5', className)}
>
{multiline ? (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
rows={3}
/>
) : (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'h-auto py-1')}
/>
)}
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSaving}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.div>
) : (
<motion.button
key="viewing"
type="button"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
onClick={() => setIsEditing(true)}
className={cn(
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
'hover:bg-muted/60 transition-colors text-left cursor-text',
variantStyles[variant],
className
)}
>
<span className={cn(!value && 'text-muted-foreground italic')}>
{value || placeholder}
</span>
<Pencil className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-50 transition-opacity" />
</motion.button>
)}
</AnimatePresence>
)
}