Add agentic dashboard and phase tracking UI components
- Add project tabs with chat and dashboard views - Add agentic dashboard with progress timeline - Add phase indicator and progress timeline components - Add activity feed and metric card components - Add status badge component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ProjectChat } from '@/components/chat/project-chat'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Settings } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Database } from '@/types/database'
|
||||
import { ProjectTabs } from './project-tabs'
|
||||
|
||||
type Project = Database['public']['Tables']['projects']['Row']
|
||||
type Message = Database['public']['Tables']['messages']['Row']
|
||||
@@ -24,7 +24,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Get messages for this project
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
@@ -33,7 +32,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||
{/* Project Header */}
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -58,10 +56,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Interface */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ProjectChat projectId={id} initialMessages={messages || []} />
|
||||
</div>
|
||||
<ProjectTabs
|
||||
projectId={id}
|
||||
projectName={project.name}
|
||||
initialMessages={messages || []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
55
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
55
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ProjectChat } from '@/components/chat/project-chat'
|
||||
import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard'
|
||||
import { MessageSquare, Workflow } from 'lucide-react'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Message = Database['public']['Tables']['messages']['Row']
|
||||
|
||||
interface ProjectTabsProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialMessages: Message[]
|
||||
}
|
||||
|
||||
export function ProjectTabs({ projectId, projectName, initialMessages }: ProjectTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="chat" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="border-b bg-card/50 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<TabsList className="h-10 bg-transparent p-0 gap-4">
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="workflow"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||
>
|
||||
<Workflow className="w-4 h-4 mr-2" />
|
||||
Workflow
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 data-[state=inactive]:hidden">
|
||||
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AgenticDashboard
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ProgressTimeline, PHASES, type Phase } from '@/components/ui/progress-timeline'
|
||||
import { PhaseIndicator } from '@/components/ui/phase-indicator'
|
||||
import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { ActivityFeed, type Activity } from '@/components/ui/activity-feed'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
Lightbulb,
|
||||
Map,
|
||||
Hammer,
|
||||
Rocket,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Zap,
|
||||
Clock,
|
||||
Target,
|
||||
TrendingUp
|
||||
} from 'lucide-react'
|
||||
|
||||
const phaseIcons: Record<Phase, React.ReactNode> = {
|
||||
think: <Brain className="w-5 h-5" />,
|
||||
evaluate: <Search className="w-5 h-5" />,
|
||||
ideate: <Lightbulb className="w-5 h-5" />,
|
||||
plan: <Map className="w-5 h-5" />,
|
||||
create: <Hammer className="w-5 h-5" />,
|
||||
deploy: <Rocket className="w-5 h-5" />,
|
||||
analyze: <BarChart3 className="w-5 h-5" />,
|
||||
enhance: <Sparkles className="w-5 h-5" />
|
||||
}
|
||||
|
||||
const phaseDescriptions: Record<Phase, string> = {
|
||||
think: 'Define the problem space and user needs',
|
||||
evaluate: 'Assess technical feasibility and risks',
|
||||
ideate: 'Generate solution options through divergent thinking',
|
||||
plan: 'Select best approach and break into tasks',
|
||||
create: 'Build the MVP with iterative development',
|
||||
deploy: 'Ship to production with monitoring',
|
||||
analyze: 'Collect metrics and user feedback',
|
||||
enhance: 'Iterate based on data insights'
|
||||
}
|
||||
|
||||
interface AgenticDashboardProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialPhase?: Phase
|
||||
initialCompleted?: Phase[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AgenticDashboard({
|
||||
projectName,
|
||||
initialPhase = 'think',
|
||||
initialCompleted = [],
|
||||
className
|
||||
}: AgenticDashboardProps) {
|
||||
const [currentPhase, setCurrentPhase] = useState<Phase>(initialPhase)
|
||||
const [completedPhases, setCompletedPhases] = useState<Phase[]>(initialCompleted)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null)
|
||||
|
||||
const [activities] = useState<Activity[]>([
|
||||
{ id: '1', type: 'success', message: 'Project initialized successfully', timestamp: new Date(Date.now() - 3600000) },
|
||||
{ id: '2', type: 'phase_change', message: 'Started Think phase', timestamp: new Date(Date.now() - 1800000) },
|
||||
{ id: '3', type: 'action', message: 'Analyzing user requirements...', timestamp: new Date(Date.now() - 900000) },
|
||||
])
|
||||
|
||||
const progress = (completedPhases.length / PHASES.length) * 100
|
||||
const currentIndex = PHASES.indexOf(currentPhase)
|
||||
|
||||
const handleAdvancePhase = () => {
|
||||
if (currentIndex < PHASES.length - 1) {
|
||||
setCompletedPhases([...completedPhases, currentPhase])
|
||||
setCurrentPhase(PHASES[currentIndex + 1])
|
||||
} else {
|
||||
setCompletedPhases([...completedPhases, currentPhase])
|
||||
setCurrentPhase('think')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setCurrentPhase('think')
|
||||
setCompletedPhases([])
|
||||
setIsRunning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{projectName}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge variant={isRunning ? 'info' : 'default'} pulse={isRunning}>
|
||||
{isRunning ? 'Running' : 'Paused'}
|
||||
</StatusBadge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Iteration #{Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
>
|
||||
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={handleAdvancePhase} className="gap-2">
|
||||
Advance <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ProgressTimeline
|
||||
currentPhase={currentPhase}
|
||||
completedPhases={completedPhases}
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2 mt-4 text-sm text-muted-foreground">
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
<span>complete</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Phase Grid + Details */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Phase Cards */}
|
||||
<div className="lg:col-span-2 grid sm:grid-cols-2 gap-3">
|
||||
{PHASES.map((phase) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
const status = isCompleted ? 'completed' : isCurrent ? 'in_progress' : 'not_started'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setSelectedPhase(phase)}
|
||||
>
|
||||
<Card className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
isCurrent && 'border-blue-500/50 bg-blue-500/5',
|
||||
isCompleted && 'border-emerald-500/50 bg-emerald-500/5',
|
||||
selectedPhase === phase && 'ring-2 ring-foreground/10'
|
||||
)}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
isCurrent && 'bg-blue-500/10 text-blue-500',
|
||||
isCompleted && 'bg-emerald-500/10 text-emerald-500',
|
||||
!isCurrent && !isCompleted && 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{phaseIcons[phase]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium capitalize">{phase}</span>
|
||||
<PhaseIndicator phase={phase} status={status} showLabel={false} size="sm" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{phaseDescriptions[phase]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityFeed activities={activities} maxItems={5} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="Progress"
|
||||
value={progress}
|
||||
format="percent"
|
||||
icon={<Target className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Phases Complete"
|
||||
value={completedPhases.length}
|
||||
trend="up"
|
||||
trendValue={`of ${PHASES.length}`}
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Time in Phase"
|
||||
value={2.5}
|
||||
format="duration"
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Iterations"
|
||||
value={Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||
icon={<RotateCcw className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedPhase && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
{phaseIcons[selectedPhase]}
|
||||
</div>
|
||||
<div>
|
||||
<span className="capitalize">{selectedPhase}</span>
|
||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||
{phaseDescriptions[selectedPhase]}
|
||||
</p>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Entry Criteria</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Previous phase completed</li>
|
||||
<li>• Required inputs available</li>
|
||||
<li>• Team capacity confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Key Activities</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Research & discovery</li>
|
||||
<li>• Documentation</li>
|
||||
<li>• Stakeholder alignment</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Exit Criteria</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Deliverables complete</li>
|
||||
<li>• Quality checks passed</li>
|
||||
<li>• Ready for next phase</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPhase(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/components/ui/activity-feed.tsx
Normal file
80
src/components/ui/activity-feed.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface Activity {
|
||||
id: string
|
||||
type: 'phase_change' | 'action' | 'notification' | 'error' | 'success'
|
||||
message: string
|
||||
timestamp: Date
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: Activity[]
|
||||
maxItems?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
phase_change: { icon: ArrowRight, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
action: { icon: Zap, color: 'text-violet-500', bg: 'bg-violet-500/10' },
|
||||
notification: { icon: Info, color: 'text-muted-foreground', bg: 'bg-muted' },
|
||||
error: { icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' },
|
||||
success: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
}
|
||||
|
||||
export function ActivityFeed({ activities, maxItems = 10, className }: ActivityFeedProps) {
|
||||
const displayActivities = activities.slice(0, maxItems)
|
||||
|
||||
if (displayActivities.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-8 text-muted-foreground', className)}>
|
||||
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">No activity yet</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayActivities.map((activity, index) => {
|
||||
const config = typeConfig[activity.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, x: -20, height: 0 }}
|
||||
animate={{ opacity: 1, x: 0, height: 'auto' }}
|
||||
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-card border hover:border-foreground/10 transition-colors"
|
||||
>
|
||||
<div className={cn('p-1.5 rounded-full shrink-0', config.bg)}>
|
||||
<Icon className={cn('w-3.5 h-3.5', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">{activity.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/components/ui/metric-card.tsx
Normal file
76
src/components/ui/metric-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: number
|
||||
format?: 'number' | 'percent' | 'duration'
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
trendValue?: string
|
||||
icon?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value, format = 'number' }: { value: number; format?: string }) {
|
||||
const spring = useSpring(0, { stiffness: 100, damping: 30 })
|
||||
const display = useTransform(spring, (current) => {
|
||||
if (format === 'percent') return `${Math.round(current)}%`
|
||||
if (format === 'duration') return `${current.toFixed(1)}s`
|
||||
return Math.round(current).toLocaleString()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(value)
|
||||
}, [spring, value])
|
||||
|
||||
return <motion.span>{display}</motion.span>
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
format = 'number',
|
||||
trend,
|
||||
trendValue,
|
||||
icon,
|
||||
className
|
||||
}: MetricCardProps) {
|
||||
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -2, transition: { duration: 0.2 } }}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border bg-card hover:border-foreground/10 transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
<AnimatedNumber value={value} format={format} />
|
||||
</span>
|
||||
{trend && trendValue && (
|
||||
<span className={cn(
|
||||
'flex items-center gap-0.5 text-xs font-medium pb-1',
|
||||
trend === 'up' && 'text-emerald-500',
|
||||
trend === 'down' && 'text-red-500',
|
||||
trend === 'neutral' && 'text-muted-foreground'
|
||||
)}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/phase-indicator.tsx
Normal file
58
src/components/ui/phase-indicator.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Circle, Loader2, AlertTriangle, X, SkipForward } from 'lucide-react'
|
||||
|
||||
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed' | 'skipped'
|
||||
|
||||
interface PhaseIndicatorProps {
|
||||
phase: string
|
||||
status: PhaseStatus
|
||||
label?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const statusConfig: Record<PhaseStatus, { icon: typeof Circle; color: string; bg: string; ring: string; animate?: boolean }> = {
|
||||
not_started: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted', ring: '' },
|
||||
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10', ring: 'ring-2 ring-blue-500/30', animate: true },
|
||||
completed: { icon: Check, color: 'text-emerald-500', bg: 'bg-emerald-500/10', ring: '' },
|
||||
blocked: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', ring: 'ring-2 ring-amber-500/30' },
|
||||
failed: { icon: X, color: 'text-red-500', bg: 'bg-red-500/10', ring: '' },
|
||||
skipped: { icon: SkipForward, color: 'text-muted-foreground/50', bg: 'bg-muted/50', ring: '' },
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { wrapper: 'p-1.5', icon: 'w-3 h-3', text: 'text-xs' },
|
||||
md: { wrapper: 'p-2', icon: 'w-4 h-4', text: 'text-sm' },
|
||||
lg: { wrapper: 'p-3', icon: 'w-5 h-5', text: 'text-base' },
|
||||
}
|
||||
|
||||
export function PhaseIndicator({ phase, status, label, size = 'md', showLabel = true }: PhaseIndicatorProps) {
|
||||
const config = statusConfig[status]
|
||||
const sizes = sizeConfig[size]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<motion.div
|
||||
className={cn('rounded-full transition-all duration-300', sizes.wrapper, config.bg, config.ring)}
|
||||
animate={status === 'in_progress' ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
<Icon className={cn(sizes.icon, config.color, config.animate && 'animate-spin')} />
|
||||
</motion.div>
|
||||
{showLabel && (
|
||||
<span className={cn('font-medium capitalize', sizes.text, status === 'in_progress' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{label || phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
141
src/components/ui/progress-timeline.tsx
Normal file
141
src/components/ui/progress-timeline.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Circle, Loader2 } from 'lucide-react'
|
||||
|
||||
export const PHASES = ['think', 'evaluate', 'ideate', 'plan', 'create', 'deploy', 'analyze', 'enhance'] as const
|
||||
export type Phase = typeof PHASES[number]
|
||||
|
||||
interface ProgressTimelineProps {
|
||||
currentPhase: Phase
|
||||
completedPhases: Phase[]
|
||||
className?: string
|
||||
variant?: 'horizontal' | 'vertical'
|
||||
showLabels?: boolean
|
||||
}
|
||||
|
||||
export function ProgressTimeline({
|
||||
currentPhase,
|
||||
completedPhases,
|
||||
className,
|
||||
variant = 'horizontal',
|
||||
showLabels = true
|
||||
}: ProgressTimelineProps) {
|
||||
const progress = (completedPhases.length / PHASES.length) * 100
|
||||
|
||||
if (variant === 'vertical') {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
{PHASES.map((phase, index) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
const isPast = PHASES.indexOf(phase) < PHASES.indexOf(currentPhase)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300',
|
||||
isCompleted && 'bg-emerald-500 text-white',
|
||||
isCurrent && 'bg-blue-500 text-white ring-4 ring-blue-500/20',
|
||||
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
animate={isCurrent ? { scale: [1, 1.1, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : isCurrent ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4" />
|
||||
)}
|
||||
</motion.div>
|
||||
{index < PHASES.length - 1 && (
|
||||
<div className={cn(
|
||||
'w-0.5 h-6 transition-colors duration-300',
|
||||
isPast || isCompleted ? 'bg-emerald-500' : 'bg-muted'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
{showLabels && (
|
||||
<span className={cn(
|
||||
'text-sm capitalize transition-colors',
|
||||
isCurrent && 'font-semibold text-foreground',
|
||||
isCompleted && 'text-emerald-600',
|
||||
!isCurrent && !isCompleted && 'text-muted-foreground'
|
||||
)}>
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className="relative h-2 bg-muted rounded-full overflow-hidden mb-4">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-violet-500 to-emerald-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500/50 via-violet-500/50 to-emerald-500/50 blur-sm"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
{PHASES.map((phase, index) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-3 h-3 rounded-full transition-all duration-300 mb-1',
|
||||
isCompleted && 'bg-emerald-500',
|
||||
isCurrent && 'bg-blue-500 ring-4 ring-blue-500/20',
|
||||
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
|
||||
)}
|
||||
animate={isCurrent ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
/>
|
||||
{showLabels && (
|
||||
<span className={cn(
|
||||
'text-xs capitalize hidden sm:block',
|
||||
isCurrent && 'font-semibold text-foreground',
|
||||
!isCurrent && 'text-muted-foreground'
|
||||
)}>
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/ui/status-badge.tsx
Normal file
50
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const variants = {
|
||||
default: 'bg-muted text-muted-foreground border-muted',
|
||||
success: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
||||
warning: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||
error: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||
info: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||
purple: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant?: keyof typeof variants
|
||||
children: React.ReactNode
|
||||
pulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, pulse, className }: StatusBadgeProps) {
|
||||
const pulseColor = {
|
||||
default: 'bg-muted-foreground',
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
purple: 'bg-violet-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
variants[variant],
|
||||
className
|
||||
)}>
|
||||
{pulse && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={cn(
|
||||
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
|
||||
pulseColor[variant]
|
||||
)} />
|
||||
<span className={cn(
|
||||
'relative inline-flex rounded-full h-2 w-2',
|
||||
pulseColor[variant]
|
||||
)} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user