Skip to content

Commit 145b908

Browse files
committed
refactor: AI피드백 애니메이션 개선 및 디자인 개선
1 parent 67dd625 commit 145b908

File tree

4 files changed

+221
-80
lines changed

4 files changed

+221
-80
lines changed

src/components/sections/TodayQuizSection.tsx

Lines changed: 82 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -73,65 +73,48 @@ const TodayQuizSection: React.FC = () => {
7373
const [feedbackContent, setFeedbackContent] = useState<string>('');
7474
const [isStreamingComplete, setIsStreamingComplete] = useState(false);
7575
const [isCorrectFromAI, setIsCorrectFromAI] = useState<boolean | null>(null);
76-
const [displayedResult, setDisplayedResult] = useState<string>('');
77-
const [displayedFeedback, setDisplayedFeedback] = useState<string>('');
76+
const [resultChars, setResultChars] = useState<string[]>([]);
77+
const [feedbackChars, setFeedbackChars] = useState<string[]>([]);
7878
const { openModal } = useModal();
7979

8080
const subscriptionId = searchParams.get('subscriptionId');
8181
const quizId = searchParams.get('quizId');
8282

83-
// 타이핑 애니메이션 효과를 위한 useEffect
83+
// 결과 텍스트를 글자별로 분리하고 애니메이션 적용
8484
React.useEffect(() => {
8585
if (!feedbackResult) {
86-
setDisplayedResult('');
86+
setResultChars([]);
8787
return;
8888
}
8989

90-
let index = 0;
91-
setDisplayedResult('');
92-
93-
const interval = setInterval(() => {
94-
if (index < feedbackResult.length) {
95-
const char = feedbackResult[index];
96-
if (char !== undefined) {
97-
// 공백 문자를 명시적으로 처리
98-
const displayChar = char === ' ' ? ' ' : char;
99-
setDisplayedResult(prev => prev + displayChar);
100-
}
101-
index++;
102-
} else {
103-
clearInterval(interval);
104-
}
105-
}, 50); // 50ms마다 한 글자씩 (더 빠르게)
90+
// 3초 후에 글자별 애니메이션 시작
91+
const initialDelay = setTimeout(() => {
92+
// 마침표 후에 줄바꿈 추가하여 텍스트 처리
93+
const processedText = feedbackResult.replace(/\./g, '.\n');
94+
const chars = processedText.split('');
95+
setResultChars(chars);
96+
}, 3000);
10697

107-
return () => clearInterval(interval);
98+
return () => clearTimeout(initialDelay);
10899
}, [feedbackResult]);
109100

101+
// 피드백 텍스트를 글자별로 분리하고 애니메이션 적용
110102
React.useEffect(() => {
111-
if (!feedbackContent) {
112-
setDisplayedFeedback('');
103+
if (!feedbackContent || resultChars.length === 0) {
104+
setFeedbackChars([]);
113105
return;
114106
}
115107

116-
let index = 0;
117-
setDisplayedFeedback('');
118-
119-
const interval = setInterval(() => {
120-
if (index < feedbackContent.length) {
121-
const char = feedbackContent[index];
122-
if (char !== undefined) {
123-
// 공백 문자를 명시적으로 처리
124-
const displayChar = char === ' ' ? ' ' : char;
125-
setDisplayedFeedback(prev => prev + displayChar);
126-
}
127-
index++;
128-
} else {
129-
clearInterval(interval);
130-
}
131-
}, 50); // 50ms마다 한 글자씩 (더 빠르게)
108+
// 결과 부분이 완료된 후 1초 후에 피드백 애니메이션 시작
109+
const timer = setTimeout(() => {
110+
// 마침표 후에 줄바꿈 추가하여 텍스트 처리
111+
const processedText = feedbackContent.replace(/\./g, '.\n');
112+
const chars = processedText.split('');
113+
setFeedbackChars(chars);
114+
}, 1000);
132115

133-
return () => clearInterval(interval);
134-
}, [feedbackContent]);
116+
return () => clearTimeout(timer);
117+
}, [feedbackContent, resultChars.length]);
135118

136119
// 답변 제출 후 게이지 애니메이션 효과
137120
React.useEffect(() => {
@@ -295,16 +278,15 @@ const TodayQuizSection: React.FC = () => {
295278

296279
try {
297280
// SSE를 통한 AI 피드백 스트리밍
298-
console.log('AI 피드백 스트리밍 시작:', `/quizzes/${answerId}/feedback`);
299281
setStreamingFeedback('');
300282
setFeedbackResult('');
301283
setFeedbackContent('');
302284
setIsStreamingComplete(false);
303285
setIsCorrectFromAI(null);
304-
setDisplayedResult('');
305-
setDisplayedFeedback('');
286+
setResultChars([]);
287+
setFeedbackChars([]);
306288

307-
const eventSource = quizAPI.streamAiFeedback(
289+
quizAPI.streamAiFeedback(
308290
answerId,
309291
// onData: 스트리밍 데이터 수신
310292
(data: string) => {
@@ -334,10 +316,11 @@ const TodayQuizSection: React.FC = () => {
334316

335317
if (feedbackIndex === -1) {
336318
// 피드백 부분이 아직 안 나옴 - 결과 부분만 업데이트
337-
setFeedbackResult(newText.replace(/^(:|:)/, '').trim());
319+
const resultText = newText.replace(/^(:|:)\s*/, '').trim();
320+
setFeedbackResult(resultText);
338321
} else {
339322
// 피드백 부분이 나옴 - 결과와 피드백 분리
340-
const resultPart = newText.substring(0, feedbackIndex).replace(/^(:|:)/, '').trim();
323+
const resultPart = newText.substring(0, feedbackIndex).replace(/^(:|:)\s*/, '').trim();
341324
const feedbackPart = newText.substring(feedbackIndex + 3).trim(); // '피드백:' 이후
342325

343326
setFeedbackResult(resultPart);
@@ -685,19 +668,21 @@ const TodayQuizSection: React.FC = () => {
685668
{displayQuiz?.quizType === 'SUBJECTIVE' && (
686669
<div className="p-4 bg-blue-50 rounded-xl mb-6">
687670
<h4 className="text-lg font-bold text-gray-900 mb-2">AI 피드백</h4>
688-
{isAiFeedbackLoading && !isCorrectFromAI && !feedbackResult && !feedbackContent ? (
671+
{(isAiFeedbackLoading && !feedbackResult) || (feedbackResult && resultChars.length === 0) ? (
689672
<div className="flex items-center space-x-3">
690673
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
691-
<p className="text-blue-700">AI가 피드백을 생성하고 있습니다...</p>
674+
<p className="text-blue-700">
675+
{!feedbackResult ? 'AI가 피드백을 생성하고 있습니다...' : 'AI 피드백을 준비하고 있습니다...'}
676+
</p>
692677
</div>
693678
) : (
694679
<div>
695680
{/* 정답/오답 결과 배지 */}
696681
{isCorrectFromAI !== null && (
697-
<div className={`inline-flex items-center rounded-full px-4 py-2 mb-3 ${
682+
<div className={`inline-flex items-center rounded-full px-4 py-2 mb-3 animate-reveal-down ${
698683
isCorrectFromAI ? 'bg-green-100' : 'bg-red-100'
699684
}`}>
700-
<span className={`text-sm font-bold ${
685+
<span className={`text-sm font-bold animate-text-reveal-up ${
701686
isCorrectFromAI ? 'text-green-700' : 'text-red-700'
702687
}`}>
703688
{isCorrectFromAI ? '🎉 정답입니다!' : '❌ 틀렸습니다!'}
@@ -706,47 +691,65 @@ const TodayQuizSection: React.FC = () => {
706691
)}
707692

708693
{/* 결과 설명 */}
709-
{feedbackResult && (
710-
<div className={`p-3 rounded-lg mb-3 transform transition-all duration-500 ease-in-out ${
694+
{resultChars.length > 0 && (
695+
<div className={`p-3 rounded-lg mb-3 animate-reveal-up ${
711696
isCorrectFromAI ? 'bg-green-50' : 'bg-red-50'
712697
}`}>
713698
<p className={`text-sm leading-relaxed whitespace-pre-wrap break-words ${
714699
isCorrectFromAI ? 'text-green-800' : 'text-red-800'
715700
}`}>
716-
<span className="font-semibold">
701+
<span className="font-semibold animate-text-reveal-down">
717702
{isCorrectFromAI ? '정답: ' : '오답: '}
718703
</span>
719-
<span className="animate-fade-in-soft" style={{
720-
wordSpacing: '0.25em',
721-
letterSpacing: '0.02em',
722-
whiteSpace: 'pre-wrap',
723-
display: 'inline'
724-
}}>
725-
{displayedResult}
726-
</span>
727-
{(displayedResult.length < feedbackResult.length || (!isStreamingComplete && !feedbackContent)) && (
728-
<span className="animate-pulse text-blue-600 ml-1"></span>
729-
)}
704+
{resultChars.map((char, index) => {
705+
if (char === '\n') {
706+
return <br key={`result-br-${index}`} />;
707+
}
708+
return (
709+
<span
710+
key={`result-${index}`}
711+
className="inline-block opacity-0"
712+
style={{
713+
animation: `wave-reveal 0.6s cubic-bezier(0.18,0.89,0.82,1.04) forwards`,
714+
animationDelay: `${index * 50}ms`,
715+
wordSpacing: '0.25em',
716+
letterSpacing: '0.02em'
717+
}}
718+
>
719+
{char}
720+
</span>
721+
);
722+
})}
730723
</p>
731724
</div>
732725
)}
733726

734-
{/* 피드백 내용 */}
735-
{feedbackContent && (
736-
<div className="p-3 bg-blue-50 rounded-lg transform transition-all duration-500 ease-in-out">
727+
{/* 피드백 내용 - 결과 부분이 완료된 후에만 표시 */}
728+
{feedbackChars.length > 0 && (
729+
<div className="p-3 bg-blue-50 rounded-lg animate-reveal-up">
737730
<p className="text-blue-800 text-sm leading-relaxed whitespace-pre-wrap break-words">
738-
<span className="font-semibold">피드백: </span>
739-
<span className="animate-fade-in-soft" style={{
740-
wordSpacing: '0.25em',
741-
letterSpacing: '0.02em',
742-
whiteSpace: 'pre-wrap',
743-
display: 'inline'
744-
}}>
745-
{displayedFeedback}
731+
<span className="font-semibold animate-text-reveal-down">
732+
피드백
746733
</span>
747-
{(displayedFeedback.length < feedbackContent.length || !isStreamingComplete) && (
748-
<span className="animate-pulse text-blue-600 ml-1"></span>
749-
)}
734+
{feedbackChars.map((char, index) => {
735+
if (char === '\n') {
736+
return <br key={`feedback-br-${index}`} />;
737+
}
738+
return (
739+
<span
740+
key={`feedback-${index}`}
741+
className="inline-block opacity-0"
742+
style={{
743+
animation: `wave-reveal 0.6s cubic-bezier(0.18,0.89,0.82,1.04) forwards`,
744+
animationDelay: `${index * 50}ms`,
745+
wordSpacing: '0.25em',
746+
letterSpacing: '0.02em'
747+
}}
748+
>
749+
{char}
750+
</span>
751+
);
752+
})}
750753
</p>
751754
</div>
752755
)}

src/index.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,74 @@
44
@tailwind components;
55
@tailwind utilities;
66

7+
/* Custom animations */
8+
@keyframes reveal-up {
9+
0% {
10+
opacity: 0;
11+
transform: translateY(80%);
12+
filter: blur(0.3rem);
13+
}
14+
100% {
15+
opacity: 1;
16+
transform: translateY(0);
17+
filter: blur(0);
18+
}
19+
}
20+
21+
@keyframes reveal-down {
22+
0% {
23+
opacity: 0;
24+
transform: translateY(-80%);
25+
filter: blur(0.3rem);
26+
}
27+
100% {
28+
opacity: 1;
29+
transform: translateY(0);
30+
filter: blur(0);
31+
}
32+
}
33+
34+
@keyframes text-reveal-up {
35+
0% {
36+
opacity: 0;
37+
transform: translateY(20px);
38+
filter: blur(0.2rem);
39+
}
40+
100% {
41+
opacity: 1;
42+
transform: translateY(0);
43+
filter: blur(0);
44+
}
45+
}
46+
47+
@keyframes text-reveal-down {
48+
0% {
49+
opacity: 0;
50+
transform: translateY(-20px);
51+
filter: blur(0.2rem);
52+
}
53+
100% {
54+
opacity: 1;
55+
transform: translateY(0);
56+
filter: blur(0);
57+
}
58+
}
59+
60+
@keyframes wave-reveal {
61+
0% {
62+
opacity: 0;
63+
filter: blur(0.3rem);
64+
}
65+
50% {
66+
opacity: 0.5;
67+
filter: blur(0.15rem);
68+
}
69+
100% {
70+
opacity: 1;
71+
filter: blur(0);
72+
}
73+
}
74+
775
body {
876
margin: 0;
977
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

src/utils/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const quizAPI = {
155155
streamAiFeedback: (answerId: string, onData: (data: string) => void, onComplete: () => void, onError: (error: Event) => void) => {
156156
const { NODE_ENV } = import.meta.env;
157157
const API_BASE_URL = NODE_ENV === 'prod' ? 'https://cs25.co.kr' : 'http://localhost:8080';
158-
const eventSource = new EventSource(`${API_BASE_URL}/quizzes/${answerId}/feedback`, { withCredentials: true });
158+
const eventSource = new EventSource(`${API_BASE_URL}/quizzes/answers/${answerId}/feedback-sentence`, { withCredentials: true });
159159

160160
eventSource.onmessage = (event) => {
161161
onData(event.data);

0 commit comments

Comments
 (0)