@@ -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 ) }
0 commit comments