Skip to content

Commit 1916b72

Browse files
committed
feat(upload): enhance loading screen with step-by-step progress visualization
- Add animated step-by-step progress tracking with icons for each extraction phase - Implement StepRow component with status indicators (pending, active, complete) - Add QuickLoader component for fast processing without resume uploads - Replace simple message rotation with detailed extraction steps showing durations - Add framer-motion animations for smooth transitions and visual feedback - Include step icons (FileText, User, Briefcase, Sparkles, Wand2, Check) for clarity - Add hasResume prop to conditionally show detailed or quick loading UI - Improve UX with pulsing active step indicator and completion checkmarks - Restructure loading logic to track individual step completion with timers
1 parent 20c5df2 commit 1916b72

File tree

2 files changed

+182
-52
lines changed

2 files changed

+182
-52
lines changed
Lines changed: 180 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,201 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4+
import { motion, AnimatePresence } from "framer-motion";
5+
import {
6+
FileText,
7+
User,
8+
Briefcase,
9+
Sparkles,
10+
Wand2,
11+
Check,
12+
} from "lucide-react";
413

514
interface LoadingScreenProps {
15+
hasResume?: boolean;
616
message?: string;
717
}
818

9-
const loadingMessages = [
10-
"Reading your content",
11-
"Extracting information",
12-
"Organizing your experience",
13-
"Polishing the details",
14-
"Almost there",
19+
interface ExtractionStep {
20+
id: number;
21+
label: string;
22+
icon: React.ComponentType<{ className?: string }>;
23+
duration: number;
24+
}
25+
26+
// Total ~45s before last step waits
27+
const extractionSteps: ExtractionStep[] = [
28+
{ id: 1, label: "Reading document", icon: FileText, duration: 3000 },
29+
{ id: 2, label: "Extracting profile", icon: User, duration: 10000 },
30+
{ id: 3, label: "Analyzing experience", icon: Briefcase, duration: 14000 },
31+
{ id: 4, label: "Processing skills", icon: Sparkles, duration: 12000 },
32+
{ id: 5, label: "Building portfolio", icon: Wand2, duration: 0 },
1533
];
1634

17-
export function LoadingScreen({ message }: LoadingScreenProps) {
18-
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
19-
const [isTransitioning, setIsTransitioning] = useState(false);
35+
type StepStatus = "pending" | "active" | "complete";
36+
37+
function StepRow({
38+
step,
39+
status,
40+
}: {
41+
step: ExtractionStep;
42+
status: StepStatus;
43+
}) {
44+
const Icon = step.icon;
45+
46+
return (
47+
<motion.div
48+
className="flex items-center gap-3"
49+
initial={{ opacity: 0, x: -8 }}
50+
animate={{ opacity: 1, x: 0 }}
51+
transition={{ duration: 0.2 }}
52+
>
53+
<div
54+
className={`
55+
w-6 h-6 rounded-full flex items-center justify-center text-xs
56+
transition-all duration-300
57+
${status === "complete" ? "bg-primary text-primary-foreground" : ""}
58+
${status === "active" ? "bg-primary/15 text-primary" : ""}
59+
${status === "pending" ? "bg-muted/50 text-muted-foreground/40" : ""}
60+
`}
61+
>
62+
<AnimatePresence mode="wait">
63+
{status === "complete" ? (
64+
<motion.div
65+
key="check"
66+
initial={{ scale: 0 }}
67+
animate={{ scale: 1 }}
68+
transition={{ type: "spring", stiffness: 400, damping: 15 }}
69+
>
70+
<Check className="w-3 h-3" strokeWidth={3} />
71+
</motion.div>
72+
) : (
73+
<motion.div
74+
key="icon"
75+
animate={status === "active" ? { scale: [1, 1.1, 1] } : {}}
76+
transition={
77+
status === "active"
78+
? { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
79+
: {}
80+
}
81+
>
82+
<Icon className="w-3 h-3" />
83+
</motion.div>
84+
)}
85+
</AnimatePresence>
86+
</div>
87+
<span
88+
className={`
89+
text-sm transition-colors duration-200
90+
${status === "complete" ? "text-muted-foreground" : ""}
91+
${status === "active" ? "text-foreground font-medium" : ""}
92+
${status === "pending" ? "text-muted-foreground/50" : ""}
93+
`}
94+
>
95+
{step.label}
96+
{status === "active" && (
97+
<motion.span
98+
className="inline-block ml-0.5 text-primary"
99+
animate={{ opacity: [1, 0.3, 1] }}
100+
transition={{ duration: 1, repeat: Infinity }}
101+
>
102+
...
103+
</motion.span>
104+
)}
105+
</span>
106+
</motion.div>
107+
);
108+
}
109+
110+
function QuickLoader({ message }: { message?: string }) {
111+
return (
112+
<div className="flex items-center justify-center min-h-[300px]">
113+
<div className="text-center space-y-4">
114+
<motion.div
115+
className="relative w-10 h-10 mx-auto"
116+
initial={{ opacity: 0 }}
117+
animate={{ opacity: 1 }}
118+
>
119+
<motion.div className="absolute inset-0 rounded-full border-2 border-primary/20" />
120+
<motion.div
121+
className="absolute inset-0 rounded-full border-2 border-primary border-t-transparent"
122+
animate={{ rotate: 360 }}
123+
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
124+
/>
125+
</motion.div>
126+
<p className="text-sm text-muted-foreground">
127+
{message || "Creating portfolio..."}
128+
</p>
129+
</div>
130+
</div>
131+
);
132+
}
133+
134+
export function LoadingScreen({
135+
hasResume = true,
136+
message,
137+
}: LoadingScreenProps) {
138+
const [currentStep, setCurrentStep] = useState(1);
139+
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
140+
141+
const getStatus = (stepId: number): StepStatus => {
142+
if (completedSteps.has(stepId)) return "complete";
143+
if (stepId === currentStep) return "active";
144+
return "pending";
145+
};
20146

21147
useEffect(() => {
22-
const messageInterval = setInterval(() => {
23-
setIsTransitioning(true);
24-
setTimeout(() => {
25-
setCurrentMessageIndex((prev) => (prev + 1) % loadingMessages.length);
26-
setIsTransitioning(false);
27-
}, 300);
28-
}, 4000);
148+
if (!hasResume) return;
29149

30-
return () => {
31-
clearInterval(messageInterval);
32-
};
33-
}, []);
150+
const step = extractionSteps.find((s) => s.id === currentStep);
151+
if (!step || step.duration === 0) return;
34152

35-
const currentMessage = message || loadingMessages[currentMessageIndex];
153+
const timer = setTimeout(() => {
154+
setCompletedSteps((prev) => new Set([...prev, currentStep]));
155+
if (currentStep < extractionSteps.length) {
156+
setCurrentStep((prev) => prev + 1);
157+
}
158+
}, step.duration);
159+
160+
return () => clearTimeout(timer);
161+
}, [currentStep, hasResume]);
162+
163+
if (!hasResume) {
164+
return <QuickLoader message={message} />;
165+
}
36166

37167
return (
38-
<div className="flex items-center justify-center min-h-[500px] p-4">
39-
<div className="w-full max-w-md">
40-
<div className="text-center space-y-10">
41-
{/* Spinner */}
42-
<div className="flex justify-center">
43-
<div className="relative w-12 h-12">
44-
<div className="absolute inset-0 border-[3px] border-border/40 rounded-full" />
45-
<div
46-
className="absolute inset-0 border-[3px] border-primary border-t-transparent rounded-full"
47-
style={{
48-
animation: "spin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite",
49-
}}
50-
/>
51-
</div>
52-
</div>
53-
54-
{/* Message */}
55-
<div className="space-y-2">
56-
<h2
57-
className={`text-lg font-medium text-foreground transition-opacity duration-300 ${
58-
isTransitioning ? "opacity-0" : "opacity-100"
59-
}`}
60-
>
61-
{currentMessage}
62-
</h2>
168+
<div className="flex items-center justify-center min-h-[350px] p-4">
169+
<motion.div
170+
className="w-full max-w-xs space-y-5"
171+
initial={{ opacity: 0, y: 10 }}
172+
animate={{ opacity: 1, y: 0 }}
173+
transition={{ duration: 0.3 }}
174+
>
175+
{/* Header */}
176+
<div className="flex items-center gap-2 text-primary">
177+
<motion.div
178+
className="w-4 h-4 rounded-full border-2 border-current border-t-transparent"
179+
animate={{ rotate: 360 }}
180+
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
181+
/>
182+
<span className="text-xs font-medium uppercase tracking-wide">
183+
AI Processing
184+
</span>
185+
</div>
63186

64-
<p className="text-sm text-muted-foreground/80">
65-
This usually takes 30-60 seconds
66-
</p>
67-
</div>
187+
{/* Steps */}
188+
<div className="space-y-2.5">
189+
{extractionSteps.map((step) => (
190+
<StepRow key={step.id} step={step} status={getStatus(step.id)} />
191+
))}
68192
</div>
69-
</div>
193+
194+
{/* Footer */}
195+
<p className="text-xs text-muted-foreground/70 pt-2">
196+
Usually takes 30-60 seconds
197+
</p>
198+
</motion.div>
70199
</div>
71200
);
72201
}

apps/main/src/components/upload/UploadWizard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ export function UploadWizard({
8888

8989
const content = useMemo(() => {
9090
if (isProcessing) {
91-
return <LoadingScreen />;
91+
const hasResume = !!upload.resume.result || !!upload.linkedin.result;
92+
return <LoadingScreen hasResume={hasResume} />;
9293
}
9394

9495
// Determine which sources have been imported

0 commit comments

Comments
 (0)