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,270 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
type StageNode = {
id: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type FlowchartTrack = {
id: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineFlowchartProps = {
tracks: FlowchartTrack[]
selectedStageId?: string | null
onStageSelect?: (stageId: string) => void
className?: string
compact?: boolean
}
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
}
const NODE_WIDTH = 140
const NODE_HEIGHT = 70
const NODE_GAP = 32
const ARROW_SIZE = 6
const TRACK_LABEL_HEIGHT = 28
const TRACK_GAP = 20
export function PipelineFlowchart({
tracks,
selectedStageId,
onStageSelect,
className,
compact = false,
}: PipelineFlowchartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
// Calculate dimensions
const nodeW = compact ? 100 : NODE_WIDTH
const nodeH = compact ? 50 : NODE_HEIGHT
const gap = compact ? 20 : NODE_GAP
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
const totalHeight =
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
const getNodePosition = useCallback(
(trackIndex: number, stageIndex: number) => {
const x = 20 + stageIndex * (nodeW + gap)
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
return { x, y }
},
[nodeW, nodeH, gap]
)
return (
<div
ref={containerRef}
className={cn('overflow-x-auto rounded-lg border bg-card', className)}
>
<svg
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
className="min-w-full"
>
<defs>
<marker
id="arrowhead"
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
refX={ARROW_SIZE}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
fill="#94a3b8"
/>
</marker>
{/* Glow filter for selected node */}
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{sortedTracks.map((track, trackIndex) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
return (
<g key={track.id}>
{/* Track label */}
<text
x={20}
y={trackY + 14}
className="fill-muted-foreground text-[11px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{track.name}
{track.kind !== 'MAIN' && ` (${track.kind})`}
</text>
{/* Arrows between stages */}
{sortedStages.map((stage, stageIndex) => {
if (stageIndex === 0) return null
const from = getNodePosition(trackIndex, stageIndex - 1)
const to = getNodePosition(trackIndex, stageIndex)
const arrowY = from.y + nodeH / 2
return (
<line
key={`arrow-${stage.id}`}
x1={from.x + nodeW}
y1={arrowY}
x2={to.x - 2}
y2={arrowY}
stroke="#94a3b8"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
)
})}
{/* Stage nodes */}
{sortedStages.map((stage, stageIndex) => {
const pos = getNodePosition(trackIndex, stageIndex)
const isSelected = selectedStageId === stage.id
const isHovered = hoveredStageId === stage.id
const colors = stageTypeColors[stage.stageType] ?? {
bg: '#f8fafc',
border: '#cbd5e1',
text: '#475569',
glow: '#64748b',
}
const projectCount = stage._count?.projectStageStates ?? 0
return (
<g
key={stage.id}
onClick={() => onStageSelect?.(stage.id)}
onMouseEnter={() => setHoveredStageId(stage.id)}
onMouseLeave={() => setHoveredStageId(null)}
className={cn(onStageSelect && 'cursor-pointer')}
filter={isSelected ? 'url(#selectedGlow)' : undefined}
>
{/* Selection ring */}
{isSelected && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={pos.x - 3}
y={pos.y - 3}
width={nodeW + 6}
height={nodeH + 6}
rx={10}
fill="none"
stroke={colors.glow}
strokeWidth={2}
strokeDasharray="none"
/>
)}
{/* Node background */}
<rect
x={pos.x}
y={pos.y}
width={nodeW}
height={nodeH}
rx={8}
fill={colors.bg}
stroke={isSelected ? colors.glow : colors.border}
strokeWidth={isSelected ? 2 : 1}
style={{
transition: 'stroke 0.15s, stroke-width 0.15s',
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
}}
/>
{/* Stage name */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 20 : 24)}
textAnchor="middle"
fill={colors.text}
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
style={{ fontFamily: 'inherit' }}
>
{stage.name.length > (compact ? 12 : 16)
? stage.name.slice(0, compact ? 10 : 14) + '...'
: stage.name}
</text>
{/* Type badge */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 34 : 40)}
textAnchor="middle"
fill={colors.text}
className="text-[9px]"
style={{ fontFamily: 'inherit', opacity: 0.7 }}
>
{stage.stageType.replace('_', ' ')}
</text>
{/* Project count */}
{!compact && projectCount > 0 && (
<>
<rect
x={pos.x + nodeW / 2 - 14}
y={pos.y + nodeH - 18}
width={28}
height={14}
rx={7}
fill={colors.border}
opacity={0.3}
/>
<text
x={pos.x + nodeW / 2}
y={pos.y + nodeH - 8}
textAnchor="middle"
fill={colors.text}
className="text-[9px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{projectCount}
</text>
</>
)}
</g>
)
})}
</g>
)
})}
</svg>
</div>
)
}

View File

@@ -0,0 +1,334 @@
'use client'
import { useState, useCallback } from 'react'
import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import {
Inbox,
Filter,
ClipboardCheck,
Trophy,
Tv,
BarChart3,
} from 'lucide-react'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import {
defaultIntakeConfig,
defaultFilterConfig,
defaultEvaluationConfig,
defaultLiveConfig,
} from '@/lib/pipeline-defaults'
import type {
IntakeConfig,
FilterConfig,
EvaluationConfig,
LiveFinalConfig,
} from '@/types/pipeline-wizard'
type StageConfigEditorProps = {
stageId: string
stageName: string
stageType: string
configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean
}
const stageIcons: Record<string, React.ReactNode> = {
INTAKE: <Inbox className="h-4 w-4" />,
FILTER: <Filter className="h-4 w-4" />,
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
SELECTION: <Trophy className="h-4 w-4" />,
LIVE_FINAL: <Tv className="h-4 w-4" />,
RESULTS: <BarChart3 className="h-4 w-4" />,
}
function ConfigSummary({
stageType,
configJson,
}: {
stageType: string
configJson: Record<string, unknown> | null
}) {
if (!configJson) {
return (
<p className="text-sm text-muted-foreground italic">
No configuration set
</p>
)
}
switch (stageType) {
case 'INTAKE': {
const config = configJson as unknown as IntakeConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Submission Window:</span>
<Badge variant="outline" className="text-[10px]">
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy}</span>
{config.lateGraceHours > 0 && (
<span className="text-muted-foreground">
({config.lateGraceHours}h grace)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">File Requirements:</span>
<span>{config.fileRequirements?.length ?? 0} configured</span>
</div>
</div>
)
}
case 'FILTER': {
const config = configJson as unknown as FilterConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span>
<span>{config.rules?.length ?? 0} eligibility rules</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]">
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{config.aiRubricEnabled && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Confidence:</span>
<span>
High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '}
{config.aiConfidenceThresholds?.medium ?? 0.6}
</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]">
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
)
}
case 'EVALUATION': {
const config = configJson as unknown as EvaluationConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span>
<span>{config.requiredReviews ?? 3}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span>
<span>
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize">
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
</span>
</div>
</div>
)
}
case 'SELECTION': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Ranking Method:</span>
<span className="capitalize">
{((configJson.rankingMethod as string) ?? 'score_average').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tie Breaker:</span>
<span className="capitalize">
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
/_/g,
' '
)}
</span>
</div>
{configJson.finalistCount != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Finalist Count:</span>
<span>{String(configJson.finalistCount)}</span>
</div>
)}
</div>
)
}
case 'LIVE_FINAL': {
const config = configJson as unknown as LiveFinalConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]">
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]">
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{config.audienceVotingEnabled && (
<span className="text-muted-foreground">
({config.audienceVoteWeight}% weight)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Reveal:</span>
<span className="capitalize">{config.revealPolicy ?? 'ceremony'}</span>
</div>
</div>
)
}
case 'RESULTS': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Publication:</span>
<span className="capitalize">
{((configJson.publicationMode as string) ?? 'manual').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Show Scores:</span>
<Badge variant="outline" className="text-[10px]">
{configJson.showDetailedScores ? 'Yes' : 'No'}
</Badge>
</div>
</div>
)
}
default:
return (
<p className="text-sm text-muted-foreground italic">
Configuration view not available for this stage type
</p>
)
}
}
export function StageConfigEditor({
stageId,
stageName,
stageType,
configJson,
onSave,
isSaving = false,
}: StageConfigEditorProps) {
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
() => configJson ?? {}
)
const handleSave = useCallback(async () => {
await onSave(stageId, localConfig)
}, [stageId, localConfig, onSave])
const renderEditor = () => {
switch (stageType) {
case 'INTAKE': {
const config = {
...defaultIntakeConfig(),
...(localConfig as object),
} as IntakeConfig
return (
<IntakeSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'FILTER': {
const config = {
...defaultFilterConfig(),
...(localConfig as object),
} as FilterConfig
return (
<FilteringSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'EVALUATION': {
const config = {
...defaultEvaluationConfig(),
...(localConfig as object),
} as EvaluationConfig
return (
<AssignmentSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'LIVE_FINAL': {
const config = {
...defaultLiveConfig(),
...(localConfig as object),
} as LiveFinalConfig
return (
<LiveFinalsSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'SELECTION':
case 'RESULTS':
return (
<div className="text-sm text-muted-foreground py-4 text-center">
Configuration for {stageType.replace('_', ' ')} stages is managed
through the stage settings.
</div>
)
default:
return null
}
}
return (
<EditableCard
title={`${stageName} Configuration`}
icon={stageIcons[stageType]}
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
onSave={handleSave}
isSaving={isSaving}
>
{renderEditor()}
</EditableCard>
)
}

View File

@@ -1,92 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Badge } from '@/components/ui/badge'
import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react'
type WizardSectionProps = {
title: string
description?: string
helpText?: string
stepNumber: number
isOpen: boolean
onToggle: () => void
isValid: boolean
hasErrors?: boolean
children: React.ReactNode
}
export function WizardSection({
title,
description,
helpText,
stepNumber,
isOpen,
onToggle,
isValid,
hasErrors,
children,
}: WizardSectionProps) {
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<Badge
variant={isValid ? 'default' : 'outline'}
className={cn(
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
isValid
? 'bg-emerald-500 text-white hover:bg-emerald-500'
: hasErrors
? 'border-destructive text-destructive'
: ''
)}
>
{isValid ? (
<CheckCircle2 className="h-4 w-4" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4" />
) : (
stepNumber
)}
</Badge>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold">{title}</h3>
{description && !isOpen && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{description}
</p>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">
{helpText && (
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300 text-sm rounded-md p-3 mb-4 flex items-start gap-2">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>{helpText}</span>
</div>
)}
{children}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Pencil, X, Loader2 } from 'lucide-react'
type EditableCardProps = {
title: string
icon?: React.ReactNode
summary: React.ReactNode
children: React.ReactNode
onSave?: () => void | Promise<void>
isSaving?: boolean
alwaysShowEdit?: boolean
defaultEditing?: boolean
className?: string
}
export function EditableCard({
title,
icon,
summary,
children,
onSave,
isSaving = false,
alwaysShowEdit = false,
defaultEditing = false,
className,
}: EditableCardProps) {
const [isEditing, setIsEditing] = useState(defaultEditing)
const handleSave = async () => {
if (onSave) {
await onSave()
}
setIsEditing(false)
}
return (
<Card className={cn('group relative overflow-hidden', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon && (
<span className="text-muted-foreground">{icon}</span>
)}
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</div>
{!isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(true)}
className={cn(
'h-7 gap-1.5 text-xs',
!alwaysShowEdit && 'opacity-0 group-hover:opacity-100 transition-opacity'
)}
>
<Pencil className="h-3 w-3" />
Edit
</Button>
)}
{isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(false)}
disabled={isSaving}
className="h-7 gap-1.5 text-xs"
>
<X className="h-3 w-3" />
Cancel
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="edit"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<div className="space-y-4">
{children}
{onSave && (
<div className="flex justify-end pt-2 border-t">
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save Changes
</Button>
</div>
)}
</div>
</motion.div>
) : (
<motion.div
key="view"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{summary}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
)
}

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>
)
}

View File

@@ -0,0 +1,262 @@
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
export type StepConfig = {
title: string
description?: string
isValid?: boolean
hasErrors?: boolean
}
type SidebarStepperProps = {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
onSave?: () => void
onSubmit?: () => void
isSaving?: boolean
isSubmitting?: boolean
saveLabel?: string
submitLabel?: string
canSubmit?: boolean
children: React.ReactNode
className?: string
}
export function SidebarStepper({
steps,
currentStep,
onStepChange,
onSave,
onSubmit,
isSaving = false,
isSubmitting = false,
saveLabel = 'Save Draft',
submitLabel = 'Create Pipeline',
canSubmit = true,
children,
className,
}: SidebarStepperProps) {
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
return (
<div className={cn('flex gap-6 min-h-[600px]', className)}>
{/* Sidebar - hidden on mobile */}
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
<nav className="flex-1 space-y-1 py-2">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
const hasErrors = step.hasErrors === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
isCurrent
? 'bg-primary/5 border border-primary/20'
: 'hover:bg-muted/50 border border-transparent'
)}
>
<div
className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-500 text-white',
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : hasErrors && !isCurrent ? (
<AlertCircle className="h-3.5 w-3.5" />
) : (
index + 1
)}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
'text-sm font-medium truncate',
isCurrent && 'text-primary',
!isCurrent && 'text-foreground'
)}
>
{step.title}
</p>
{step.description && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
{step.description}
</p>
)}
</div>
</button>
)
})}
</nav>
{/* Actions */}
<div className="border-t pt-4 space-y-2 mt-auto">
{onSave && (
<Button
variant="outline"
className="w-full justify-start"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
className="w-full justify-start"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Mobile step indicator */}
<div className="lg:hidden flex flex-col w-full">
<MobileStepIndicator
steps={steps}
currentStep={currentStep}
onStepChange={onStepChange}
/>
<div className="flex-1 mt-4">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
{/* Mobile actions */}
<div className="flex gap-2 pt-4 border-t mt-4">
{onSave && (
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
size="sm"
className="flex-1"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Desktop content */}
<div className="hidden lg:block flex-1 min-w-0">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
</div>
)
}
function MobileStepIndicator({
steps,
currentStep,
onStepChange,
}: {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
}) {
return (
<div className="overflow-x-auto">
<div className="flex items-center gap-1 pb-2 min-w-max">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<span>{index + 1}</span>
)}
<span className="hidden sm:inline">{step.title}</span>
</button>
)
})}
</div>
</div>
)
}
function StepContent({
currentStep,
direction,
children,
}: {
currentStep: number
direction: (prev: number, next: number) => number
children: React.ReactNode
}) {
const childArray = Array.isArray(children) ? children : [children]
const currentChild = childArray[currentStep]
return (
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{
duration: 0.2,
ease: 'easeInOut',
}}
>
{currentChild}
</motion.div>
</AnimatePresence>
</div>
)
}