Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions src/components/RandomQuestion.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// src/components/RandomQuestion.tsx
import { useEffect, useRef, useState } from 'react';

Check failure on line 2 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Run autofix to sort these imports!
import {
getRandomQuestion,
subscribeToNotifications,
Expand All @@ -8,9 +8,11 @@
type IRandomNotificationPayload,
} from '@/services/randomQuestionApi';
import clockFrog from '@/assets/clockFrog.svg';
import frog from '@/assets/frog.svg';

type TNotification = IRandomNotificationPayload;
const MAX_TIME = 180;
const INITIAL_TIME = 30; // 녹음 시작 전 제한 시간
const RECORDING_TIME = 80; // 녹음 후 전체 시간
const isDev = import.meta.env.DEV;

// 🔎 알림 페이로드에서 id를 안전하게 뽑아오기 (peerAnswerId 우선, 없으면 peerFeedbackId)
Expand All @@ -28,7 +30,8 @@
const [errorMessage, setErrorMessage] = useState<string | null>(null);

// ===== 타이머 상태 =====
const [remainingTime, setRemainingTime] = useState<number>(MAX_TIME);
const [remainingTime, setRemainingTime] = useState<number>(INITIAL_TIME);
const [hasStartedRecording, setHasStartedRecording] = useState(false); // 녹음을 시작했는지 여부
const countdownTimerRef = useRef<number | null>(null);

// ===== 녹음 상태 =====
Expand Down Expand Up @@ -59,6 +62,18 @@

const shouldTickPopup = showPopup && remainingTime > 0 && (isRecording || (!recordedAudio && !isPlaying));

// 이미지 선택: 30초 이하면 clockFrog, 아니면 frog
const currentImage = remainingTime <= 30 ? clockFrog : frog;

// 빨간 오버레이 opacity 계산 (30초 이하일 때만)
const redOverlayOpacity = remainingTime <= 30 ? Math.min(0.3, (30 - remainingTime) / 30 * 0.3) : 0;

Check failure on line 69 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `30·-·remainingTime)·/·30` with `(30·-·remainingTime)·/·30)`

// 진행바 색상 (녹음 시작 전: 파란색, 녹음 후: coral)
const progressBarColor = hasStartedRecording ? 'bg-coral-500' : 'bg-blue-500';

// 최대 시간 (진행바 계산용)
const maxTime = hasStartedRecording ? RECORDING_TIME : INITIAL_TIME;

const clearReconnectTimer = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
Expand All @@ -73,7 +88,7 @@
console.warn(`[SSE] reconnect in ${wait}ms (${why})`);
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null;
openSSE();

Check failure on line 91 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

'openSSE' was used before it was defined
}, wait);
};

Expand All @@ -82,7 +97,7 @@
if (esRef.current) {
try {
esRef.current.close();
} catch {}

Check failure on line 100 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Empty block statement
esRef.current = null;
}
};
Expand Down Expand Up @@ -130,7 +145,8 @@
});
latestAudioBlobRef.current = null;
setRecordingTime(0);
setRemainingTime(MAX_TIME);
setRemainingTime(INITIAL_TIME);
setHasStartedRecording(false);

await fetchRandomQuestion(id);
} catch {
Expand All @@ -142,7 +158,7 @@
closeSSE();
attemptsRef.current = 0;

const es = subscribeToNotifications(handleMessage, (errorEvt) => {

Check failure on line 161 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Promise returned in function argument where a void return was expected
console.error('SSE 연결 오류:', errorEvt);
scheduleReconnect('onerror');
}) as unknown as EventSource;
Expand Down Expand Up @@ -182,7 +198,8 @@
if (recordedAudio) URL.revokeObjectURL(recordedAudio);
latestAudioBlobRef.current = null;
setRecordingTime(0);
setRemainingTime(MAX_TIME);
setRemainingTime(INITIAL_TIME);
setHasStartedRecording(false);

if (peerAnswerId === -1) {
setLoadingQuestion(true);
Expand Down Expand Up @@ -249,7 +266,7 @@
if (prev <= 1) {
try {
mediaRecorderRef.current?.stop();
} catch {}

Check failure on line 269 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Empty block statement
setIsRecording(false);
setIsPausedRec(false);
if (countdownTimerRef.current) {
Expand Down Expand Up @@ -292,6 +309,13 @@
alert('시간이 종료되어 더 이상 녹음할 수 없습니다.');
return;
}

Check failure on line 312 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `····`
// 녹음 시작 시 전체 시간을 80초로 변경
if (!hasStartedRecording) {
setRemainingTime(RECORDING_TIME);
setHasStartedRecording(true);
}

Check failure on line 318 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `····`
try {
if (recordedAudio) {
URL.revokeObjectURL(recordedAudio);
Expand Down Expand Up @@ -371,7 +395,8 @@
setRecordingTime(0);
latestAudioBlobRef.current = null;
audioChunksRef.current = [];
setRemainingTime(MAX_TIME);
// ✅ 다시 녹음하기: 시간 초기화
setRemainingTime(RECORDING_TIME);
void startRecording();
};

Expand Down Expand Up @@ -421,6 +446,7 @@
if (isRecording) stopRecording();
if (audioRef.current) audioRef.current.pause();
setShowPopup(false);
setHasStartedRecording(false);
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
Expand All @@ -446,6 +472,7 @@
const feedback = await uploadFeedbackRecordingAndGetResult(questionDetail.question.questionId, latestAudioBlobRef.current);
alert(`AI 피드백이 도착했어요.\n\n${feedback.aiFeedback}`);
setShowPopup(false);
setHasStartedRecording(false);
} catch (err) {
console.error('랜덤 팝업 답변 제출 실패:', err);
alert('답변 제출에 실패했습니다. 잠시 후 다시 시도해주세요.');
Expand All @@ -455,7 +482,8 @@
};

const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0;
const progressPercent = Math.max(0, Math.min(100, (remainingTime / MAX_TIME) * 100));
// ✅ 진행바: 남은 시간 비율로 표시
const progressPercent = Math.max(0, Math.min(100, (remainingTime / maxTime) * 100));

return (
<>
Expand All @@ -470,7 +498,14 @@

{!showPopup ? null : (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center">
<div className="bg-white rounded-2xl p-8 shadow-lg w-full max-w-md mx-4 relative">
{/* 빨간 오버레이 */}
{redOverlayOpacity > 0 && (

Check failure on line 502 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `(⏎············<div·⏎··············className="fixed·inset-0·bg-red-500·pointer-events-none·z-[51]"⏎··············style={{·opacity:·redOverlayOpacity·}}⏎············/>⏎··········)` with `<div·className="fixed·inset-0·bg-red-500·pointer-events-none·z-[51]"·style={{·opacity:·redOverlayOpacity·}}·/>`
<div
className="fixed inset-0 bg-red-500 pointer-events-none z-[51]"
style={{ opacity: redOverlayOpacity }}
/>
)}
<div className="bg-white rounded-2xl p-8 shadow-lg w-full max-w-md mx-4 relative z-[52]">
<button onClick={handleClose} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors" aria-label="닫기">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
Expand Down Expand Up @@ -502,16 +537,20 @@
)}

<div className="flex justify-center mb-4">
<img src={clockFrog} alt="면접관" className="w-32 h-auto" />
<img src={currentImage} alt="면접관" className="w-32 h-auto" />
</div>

<p className="text-center text-sm text-gray-500 mb-4">
{remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다.` : '시간이 종료되었습니다.'}
{!hasStartedRecording ? (

Check failure on line 544 in src/components/RandomQuestion.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `·?·(`
remainingTime > 0 ? `녹음을 시작하세요 (${remainingTime}초)` : '시간이 종료되었습니다.'
) : (
remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다.` : '시간이 종료되었습니다.'
)}
</p>

<div className="mb-4">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-coral-500 transition-all duration-300" style={{ width: `${progressPercent}%` }} />
<div className={`h-full ${progressBarColor} transition-all duration-300`} style={{ width: `${progressPercent}%` }} />
</div>
<p className="text-center text-sm text-gray-500 mt-2">랜덤 팝업 질문</p>
</div>
Expand Down Expand Up @@ -619,6 +658,7 @@
.bg-coral-50 { background-color: #fff5f5; }
.bg-coral-500 { background-color: #ff7f66; }
.bg-coral-600 { background-color: #ff6b52; }
.bg-blue-500 { background-color: #3b82f6; }
.text-coral-500 { color: #ff7f66; }
.border-coral-500 { border-color: #ff7f66; }
.hover\\:bg-coral-50:hover { background-color: #fff5f5; }
Expand Down
8 changes: 4 additions & 4 deletions src/pages/Interview/feedback_result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ export default function FeedbackResult() {

// feedbackProgressStatus 확인
if (response.feedbackProgressStatus === 'WORKING') {
// 피드백 생성 중 - 5초 후 재시도
// 피드백 생성 중 - 5초 후 재시도 (로딩 상태 유지)
setTimeout(fetchFeedback, 5000);
return;
}

if (response.feedbackProgressStatus === 'FAILED') {
setError('피드백 생성에 실패했습니다.');
setError('피드백 데이터를 불러올 수 없습니다.');
setIsLoading(false);
return;
}
Expand Down Expand Up @@ -83,8 +83,8 @@ export default function FeedbackResult() {
<div className="mb-4">
<img src="src/assets/clockFrog.svg" alt="로딩" className="w-32 h-auto mx-auto animate-pulse" />
</div>
<p className="text-gray-600 text-lg">피드백을 생성하고 있습니다...</p>
<p className="text-gray-500 text-sm mt-2">최대 5분 정도 소요될 수 있습니다.</p>
<p className="text-gray-600 text-lg font-semibold">로딩 중...</p>
<p className="text-gray-500 text-sm mt-2">피드백을 불러오고 있습니다. 잠시만 기다려주세요.</p>
</div>
</div>
</InterviewLayout>
Expand Down
51 changes: 43 additions & 8 deletions src/pages/Interview/main_answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

import clockFrog from '@/assets/clockFrog.svg';
import orangeFrog from '@/assets/orangeFrog.svg';
import frog from '@/assets/frog.svg';

const MAX_TIME = 180;
const INITIAL_TIME = 30; // 녹음 시작 전 제한 시간
const RECORDING_TIME = 80; // 녹음 후 전체 시간

export default function AnswerQuestion() {
const navigate = useNavigate();
Expand Down Expand Up @@ -37,7 +39,8 @@
const [recordedAudioUrl, setRecordedAudioUrl] = useState<string | null>(null);
const [recordingTime, setRecordingTime] = useState(0);

const [remainingTime, setRemainingTime] = useState(MAX_TIME);
const [remainingTime, setRemainingTime] = useState(INITIAL_TIME);
const [hasStartedRecording, setHasStartedRecording] = useState(false); // 녹음을 시작했는지 여부

const [retryCount, setRetryCount] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
Expand All @@ -50,6 +53,7 @@
const [isPlaying, setIsPlaying] = useState(false);
const [playbackTime, setPlaybackTime] = useState(0);
const [playbackDuration, setPlaybackDuration] = useState(0);

// 남은 시간이 줄어들어야 하는지 여부:
// - 질문 노출 & 완료모달 아님 & 남은시간 > 0
// - (녹음 중) 또는 (아직 녹음본이 없음 = 최초 진입 상태)
Expand Down Expand Up @@ -78,7 +82,8 @@
latestAudioBlobRef.current = null;
setRecordingTime(0);

setRemainingTime(MAX_TIME); // 다음 질문에서만 초기화
setRemainingTime(INITIAL_TIME); // 다음 질문에서 초기화
setHasStartedRecording(false); // 녹음 시작 여부 초기화
setRetryCount(1);
}

Expand Down Expand Up @@ -153,10 +158,16 @@
return () => {
if (id) clearInterval(id);
};
}, [shouldTick, currentQuestion?.questionId, isRecording, handleTimeout]);

Check warning on line 161 in src/pages/Interview/main_answer.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'currentQuestion'. Either include it or remove the dependency array

const startRecording = async () => {
try {
// 녹음 시작 시 전체 시간을 80초로 변경
if (!hasStartedRecording) {
setRemainingTime(RECORDING_TIME);
setHasStartedRecording(true);
}

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
Expand Down Expand Up @@ -225,7 +236,7 @@
audioChunksRef.current = [];

// ✅ 다시 녹음하기: 시간 초기화
setRemainingTime(MAX_TIME);
setRemainingTime(RECORDING_TIME);

setRetryCount((c) => c - 1);
void startRecording();
Expand Down Expand Up @@ -349,9 +360,28 @@

const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0;

// 이미지 선택: 30초 이하면 clockFrog, 아니면 frog
const currentImage = remainingTime <= 30 ? clockFrog : frog;

// 빨간 오버레이 opacity 계산 (30초 이하일 때만)
const redOverlayOpacity = remainingTime <= 30 ? Math.min(0.3, (30 - remainingTime) / 30 * 0.3) : 0;

// 진행바 색상 (녹음 시작 전: 파란색, 녹음 후: coral)
const progressBarColor = hasStartedRecording ? 'bg-coral-500' : 'bg-blue-500';

// 최대 시간 (진행바 계산용)
const maxTime = hasStartedRecording ? RECORDING_TIME : INITIAL_TIME;

return (
<InterviewLayout activeMenu="answer">
<div className="flex-1 flex flex-col px-8 pt-2 max-w-[800px]">
{/* 빨간 오버레이 */}
{redOverlayOpacity > 0 && (
<div
className="fixed inset-0 bg-red-500 pointer-events-none z-10"
style={{ opacity: redOverlayOpacity }}
/>
)}
<div className="flex-1 flex flex-col px-8 pt-2 max-w-[800px] relative z-20">
{/* 상단 정보 */}
<div className="mb-4">
<span className="inline-block bg-gray-400 text-white px-4 py-1 rounded-full text-sm">{fileName}</span>
Expand Down Expand Up @@ -387,18 +417,22 @@

{/* 캐릭터 이미지 */}
<div className="flex justify-center mb-8">
<img src={clockFrog} alt="면접관" className="w-48 h-auto" />
<img src={currentImage} alt="면접관" className="w-48 h-auto" />
</div>

{/* 타이머 & 녹음 컨트롤 */}
<div className="max-w-[600px] mx-auto">
{/* 타이머 */}
<div className="mb-4">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-coral-500 transition-all duration-300" style={{ width: `${(remainingTime / MAX_TIME) * 100}%` }} />
<div className={`h-full ${progressBarColor} transition-all duration-300`} style={{ width: `${(remainingTime / maxTime) * 100}%` }} />
</div>
<p className="text-center text-sm text-gray-500 mt-2">
{remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다 ...` : '시간이 종료되었습니다.'}
{!hasStartedRecording ? (
remainingTime > 0 ? `녹음을 시작하세요 (${remainingTime}초)` : '시간이 종료되었습니다.'
) : (
remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다 ...` : '시간이 종료되었습니다.'
)}
</p>
</div>

Expand Down Expand Up @@ -533,6 +567,7 @@
.bg-coral-400 { background-color: #ff9580; }
.bg-coral-500 { background-color: #ff7f66; }
.bg-coral-600 { background-color: #ff6b52; }
.bg-blue-500 { background-color: #3b82f6; }
.text-coral-500 { color: #ff7f66; }
.border-coral-500 { border-color: #ff7f66; }
.hover\\:bg-coral-500:hover { background-color: #ff7f66; }
Expand Down
58 changes: 23 additions & 35 deletions src/pages/myInterviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

// ---------- 안전한 디폴트 값 ----------
const timedOutCount = summaryData?.result?.timedOutCount ?? 0;
const questionCards = summaryData?.result?.questionCards ?? [];

Check warning on line 42 in src/pages/myInterviews.tsx

View workflow job for this annotation

GitHub Actions / Lint

The 'questionCards' logical expression could make the dependencies of useEffect Hook (at line 66) change on every render. To fix this, wrap the initialization of 'questionCards' in its own useMemo() Hook
const answerItems = answersData?.result ?? [];
const randomItems = randomData?.result ?? [];

Expand All @@ -65,49 +65,37 @@
}
}, [questionCards, selectedQuestionId]);

// ---------- 피드백 탭: 모든 질문 피드백 로딩 ----------
// ---------- 피드백 탭: 루트 질문에 대해서만 피드백 조회 ----------
useEffect(() => {
if (activeTab !== 'feedback' || answerItems.length === 0) return;
if (activeTab !== 'feedback' || !selectedQuestionId) return;

const fetchAllFeedback = async () => {
const fetchFeedback = async () => {
setLoadingFeedback(true);
try {
const all = await Promise.all(
answerItems.map(async (answer) => {
try {
const feedback = await getQuestionFeedback(answer.questionId);
const res = (feedback as any)?.result ?? {};
return {
order: answer.order,
questionId: answer.questionId,
question: answer.question,
aiFeedback: res.aiFeedback ?? '',
selfFeedback: res.selfFeedback ?? '',
peerItems: Array.isArray(res.peerItems) ? res.peerItems : [],
} as FeedbackItem;
} catch (e) {
console.error(`질문 ${answer.questionId} 피드백 조회 실패:`, e);
return {
order: answer.order,
questionId: answer.questionId,
question: answer.question,
aiFeedback: '',
selfFeedback: '',
peerItems: [],
} as FeedbackItem;
}
}),
);
setFeedbackList(all);
const feedback = await getQuestionFeedback(selectedQuestionId);
const res = (feedback as any)?.result ?? {};

// 루트 질문에 대한 통합 피드백을 모든 질문에 표시
const feedbackItem: FeedbackItem = {
order: 1,
questionId: selectedQuestionId,
question: '전체 답변에 대한 피드백',
aiFeedback: res.aiFeedback ?? '',
selfFeedback: res.selfFeedback ?? '',
peerItems: Array.isArray(res.peerItems) ? res.peerItems : [],
};

setFeedbackList([feedbackItem]);
} catch (e) {
console.error('피드백 조회 중 오류:', e);
console.error('피드백 조회 실패:', e);
setFeedbackList([]);
} finally {
setLoadingFeedback(false);
}
};

void fetchAllFeedback();
}, [activeTab, answerItems]);
void fetchFeedback();
}, [activeTab, selectedQuestionId]);

// ---------- 오디오 재생 ----------
const handleAudioPlay = (url: string) => {
Expand Down Expand Up @@ -251,8 +239,8 @@
{(feedbackList ?? []).map((item) => (
<div key={item.questionId} className="bg-gray-50 rounded-lg p-5">
<div className="mb-4">
<span className="inline-block bg-gray-600 text-white px-3 py-1 rounded-full text-sm font-medium mr-2">질문 {item.order}</span>
<h3 className="text-lg font-semibold text-gray-900 mt-2">{item.question}</h3>
<h3 className="text-lg font-semibold text-gray-900">전체 답변에 대한 피드백</h3>
<p className="text-sm text-gray-500 mt-1">루트 질문과 모든 꼬리 질문에 대한 통합 피드백입니다.</p>
</div>

<div className="space-y-3">
Expand Down