Skip to content

Commit 3e2d964

Browse files
committed
Add ProgressIndicator component
Introduces a reusable ProgressIndicator component supporting linear, circular, and dots variants with customizable size and message. Also adds an UploadProgress component for file upload progress display.
1 parent f02d104 commit 3e2d964

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

components/progress-indicator.tsx

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client"
2+
3+
import { cn } from "@/lib/utils"
4+
import { Loader2 } from "lucide-react"
5+
6+
export interface ProgressIndicatorProps {
7+
progress?: number // 0-100
8+
message?: string
9+
variant?: "linear" | "circular" | "dots"
10+
size?: "sm" | "md" | "lg"
11+
className?: string
12+
}
13+
14+
export function ProgressIndicator({
15+
progress,
16+
message,
17+
variant = "linear",
18+
size = "md",
19+
className = "",
20+
}: ProgressIndicatorProps) {
21+
if (variant === "circular") {
22+
return <CircularProgress progress={progress} message={message} size={size} className={className} />
23+
}
24+
25+
if (variant === "dots") {
26+
return <DotsProgress message={message} size={size} className={className} />
27+
}
28+
29+
return <LinearProgress progress={progress} message={message} size={size} className={className} />
30+
}
31+
32+
function LinearProgress({
33+
progress,
34+
message,
35+
size,
36+
className,
37+
}: ProgressIndicatorProps) {
38+
const heights = {
39+
sm: "h-1",
40+
md: "h-2",
41+
lg: "h-3",
42+
}
43+
44+
return (
45+
<div className={cn("w-full space-y-2", className)}>
46+
{message && (
47+
<div className="flex items-center justify-between text-sm">
48+
<span className="text-muted-foreground">{message}</span>
49+
{progress !== undefined && (
50+
<span className="font-medium">{Math.round(progress)}%</span>
51+
)}
52+
</div>
53+
)}
54+
<div className={cn("w-full bg-muted overflow-hidden", heights[size || "md"])}>
55+
<div
56+
className={cn(
57+
"h-full bg-primary transition-all duration-300 ease-out",
58+
progress === undefined && "animate-pulse"
59+
)}
60+
style={{
61+
width: progress !== undefined ? `${progress}%` : "100%",
62+
}}
63+
/>
64+
</div>
65+
</div>
66+
)
67+
}
68+
69+
function CircularProgress({
70+
progress,
71+
message,
72+
size,
73+
className,
74+
}: ProgressIndicatorProps) {
75+
const sizes = {
76+
sm: 32,
77+
md: 48,
78+
lg: 64,
79+
}
80+
81+
const sizeValue = sizes[size || "md"]
82+
const strokeWidth = sizeValue / 8
83+
const radius = (sizeValue - strokeWidth) / 2
84+
const circumference = 2 * Math.PI * radius
85+
const offset = progress !== undefined
86+
? circumference - (progress / 100) * circumference
87+
: 0
88+
89+
return (
90+
<div className={cn("flex flex-col items-center gap-3", className)}>
91+
<div className="relative" style={{ width: sizeValue, height: sizeValue }}>
92+
{/* Background circle */}
93+
<svg
94+
className="transform -rotate-90"
95+
width={sizeValue}
96+
height={sizeValue}
97+
>
98+
<circle
99+
cx={sizeValue / 2}
100+
cy={sizeValue / 2}
101+
r={radius}
102+
stroke="currentColor"
103+
strokeWidth={strokeWidth}
104+
fill="none"
105+
className="text-muted"
106+
/>
107+
<circle
108+
cx={sizeValue / 2}
109+
cy={sizeValue / 2}
110+
r={radius}
111+
stroke="currentColor"
112+
strokeWidth={strokeWidth}
113+
fill="none"
114+
strokeDasharray={circumference}
115+
strokeDashoffset={offset}
116+
strokeLinecap="round"
117+
className={cn(
118+
"text-primary transition-all duration-300",
119+
progress === undefined && "animate-spin"
120+
)}
121+
/>
122+
</svg>
123+
{progress !== undefined && (
124+
<div className="absolute inset-0 flex items-center justify-center text-sm font-medium">
125+
{Math.round(progress)}%
126+
</div>
127+
)}
128+
</div>
129+
{message && <p className="text-sm text-muted-foreground text-center">{message}</p>}
130+
</div>
131+
)
132+
}
133+
134+
function DotsProgress({ message, size, className }: ProgressIndicatorProps) {
135+
const dotSizes = {
136+
sm: "w-1.5 h-1.5",
137+
md: "w-2 h-2",
138+
lg: "w-3 h-3",
139+
}
140+
141+
return (
142+
<div className={cn("flex flex-col items-center gap-3", className)}>
143+
<div className="flex gap-2">
144+
{[0, 1, 2].map((i) => (
145+
<div
146+
key={i}
147+
className={cn(
148+
"rounded-full bg-primary animate-pulse",
149+
dotSizes[size || "md"]
150+
)}
151+
style={{
152+
animationDelay: `${i * 0.15}s`,
153+
}}
154+
/>
155+
))}
156+
</div>
157+
{message && <p className="text-sm text-muted-foreground text-center">{message}</p>}
158+
</div>
159+
)
160+
}
161+
162+
// Inline usage component for file uploads, etc.
163+
export function UploadProgress({ fileName, progress }: { fileName: string; progress: number }) {
164+
return (
165+
<div className="border border-border bg-card p-3 space-y-2">
166+
<div className="flex items-center justify-between text-sm">
167+
<span className="truncate flex-1 mr-2">{fileName}</span>
168+
<span className="font-medium text-primary">{Math.round(progress)}%</span>
169+
</div>
170+
<div className="w-full h-1.5 bg-muted overflow-hidden">
171+
<div
172+
className="h-full bg-primary transition-all duration-200"
173+
style={{ width: `${progress}%` }}
174+
/>
175+
</div>
176+
</div>
177+
)
178+
}

0 commit comments

Comments
 (0)