diff --git a/frontend/src/app/styles/global.css b/frontend/src/app/styles/global.css index 951c0338..57063994 100644 --- a/frontend/src/app/styles/global.css +++ b/frontend/src/app/styles/global.css @@ -122,6 +122,47 @@ } } + /* 면접 스테이지 진입 — 모니터 전원이 켜지는 듯한 모션. + 중앙의 밝은 가로 라인 → 세로로 펼쳐지며 → 밝기가 정상으로 안정. */ + @keyframes screen-power-on { + 0% { + opacity: 0; + transform: scale(1.02); + filter: brightness(2) saturate(0.5) contrast(0.85); + clip-path: inset(49.5% 0 49.5% 0); + } + 18% { + opacity: 1; + filter: brightness(2.2) saturate(0.5) contrast(0.85); + clip-path: inset(49.5% 0 49.5% 0); + } + 55% { + transform: scale(1.004); + filter: brightness(1.5) saturate(0.85) contrast(0.95); + clip-path: inset(0 0 0 0); + } + 100% { + opacity: 1; + transform: scale(1); + filter: brightness(1) saturate(1) contrast(1); + clip-path: inset(0 0 0 0); + } + } + .anim-screen-power-on { + animation: screen-power-on 0.7s var(--ease-decelerate) both; + transform-origin: center; + } + + /* 음성 모드 — 재생 중 표시하는 이퀄라이저 바. */ + @keyframes eq-bar { + 0%, 100% { transform: scaleY(0.35); } + 50% { transform: scaleY(1); } + } + .anim-eq-bar { + animation: eq-bar 0.9s var(--ease-standard) infinite; + transform-origin: bottom; + } + .anim-modal-backdrop { animation: modal-fade var(--duration-normal) var(--ease-decelerate) both; } diff --git a/frontend/src/features/interview/model/useDeliveryMode.ts b/frontend/src/features/interview/model/useDeliveryMode.ts new file mode 100644 index 00000000..9d33e884 --- /dev/null +++ b/frontend/src/features/interview/model/useDeliveryMode.ts @@ -0,0 +1,33 @@ +import { useCallback, useState } from 'react' + +// 면접 중 질문 전달 방식. +// - text : 텍스트로 읽는다. 음성은 자동재생하지 않고 "대기 중" 표시 + 수동 재생. +// - voice: 음성으로 듣는다. 텍스트는 숨기고 자동재생. +export type DeliveryMode = 'text' | 'voice' + +const STORAGE_KEY = 'stackup:interview:delivery-mode' + +// 텍스트 읽는 도중 음성이 끼어드는 UX 를 막기 위해 기본값은 text. +function readStored(): DeliveryMode { + if (typeof window === 'undefined') return 'text' + try { + return window.localStorage.getItem(STORAGE_KEY) === 'voice' ? 'voice' : 'text' + } catch { + return 'text' + } +} + +export function useDeliveryMode(): [DeliveryMode, (mode: DeliveryMode) => void] { + const [mode, setMode] = useState(readStored) + + const update = useCallback((next: DeliveryMode) => { + setMode(next) + try { + window.localStorage.setItem(STORAGE_KEY, next) + } catch { + // 저장 실패는 무시 — 세션 내 상태는 유지된다. + } + }, []) + + return [mode, update] +} diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 14ba87f0..7184e5e6 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -167,6 +167,14 @@ export function useLiveInterview(sessionId: number) { const wasSegmented = useCallback((id: number) => segmentedIds.current.has(id), []) + // 첫 질문이 실제 content 를 갖고 도착했는지. 면접 스테이지 진입 전에 이걸 기다려 + // 사용자가 스테이지에 들어서면 바로 질문을 볼 수 있게 한다(빈 대기 화면 회피). + const firstQuestionReady = items.some((m) => { + if (m.role !== 'INTERVIEWER') return false + const c = (m.content ?? '').trim() + return c.length > 0 && c !== FOLLOWUP_GENERATING_TEXT + }) + return { session: sessionQuery.data, status, @@ -181,5 +189,6 @@ export function useLiveInterview(sessionId: number) { isLoading: sessionQuery.isLoading, questionStreaming, wasSegmented, + firstQuestionReady, } } diff --git a/frontend/src/features/interview/ui/live/ConversationThread.tsx b/frontend/src/features/interview/ui/live/ConversationThread.tsx index 9317ff3c..1efb29a6 100644 --- a/frontend/src/features/interview/ui/live/ConversationThread.tsx +++ b/frontend/src/features/interview/ui/live/ConversationThread.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { isQuestion } from '@/domain/session' import type { ThreadItem } from '../../model/useLiveInterview' +import type { DeliveryMode } from '../../model/useDeliveryMode' import { QuestionBubble } from './QuestionBubble' import { AnswerBubble } from './AnswerBubble' import { TypingIndicator } from './TypingIndicator' @@ -8,9 +9,11 @@ import { TypingIndicator } from './TypingIndicator' export function ConversationThread({ items, awaitingQuestion, + mode = 'text', }: { items: ThreadItem[] awaitingQuestion: boolean + mode?: DeliveryMode }) { // 내부 스레드 컨테이너만 스크롤한다. scrollIntoView 는 스크롤 가능한 모든 // 조상(=window)까지 스크롤해 페이지가 푸터로 끌려 내려가므로 사용하지 않는다. @@ -27,7 +30,7 @@ export function ConversationThread({
{items.map((item) => isQuestion(item) ? ( - + ) : ( ), diff --git a/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx b/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx new file mode 100644 index 00000000..d4b3c80e --- /dev/null +++ b/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react' +import type { DeliveryMode } from '../../model/useDeliveryMode' + +function TextIcon() { + return ( + + + + ) +} + +function VoiceIcon() { + return ( + + + + + ) +} + +const options: { value: DeliveryMode; label: string; icon: () => ReactElement }[] = [ + { value: 'text', label: '텍스트', icon: TextIcon }, + { value: 'voice', label: '음성', icon: VoiceIcon }, +] + +// 면접 진행 방식(텍스트/음성)을 고르는 컴팩트 세그먼트 토글. +export function DeliveryModeToggle({ + value, + onChange, +}: { + value: DeliveryMode + onChange: (mode: DeliveryMode) => void +}) { + return ( +
+ {options.map(({ value: v, label, icon: Icon }) => { + const active = value === v + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/features/interview/ui/live/InterviewLobby.tsx b/frontend/src/features/interview/ui/live/InterviewLobby.tsx index ee3cc886..40cf4429 100644 --- a/frontend/src/features/interview/ui/live/InterviewLobby.tsx +++ b/frontend/src/features/interview/ui/live/InterviewLobby.tsx @@ -7,7 +7,7 @@ export function InterviewLobby({ sessionId, session }: { sessionId: number; sess const { start } = useSessionLifecycle(sessionId) const progress = sessionProgress(session) return ( -
+

{session.title ?? '모의 면접'}

총 {progress.max}개의 질문이 준비됩니다. 시작하면 첫 질문이 곧 도착합니다. diff --git a/frontend/src/features/interview/ui/live/InterviewPreparing.tsx b/frontend/src/features/interview/ui/live/InterviewPreparing.tsx new file mode 100644 index 00000000..34606351 --- /dev/null +++ b/frontend/src/features/interview/ui/live/InterviewPreparing.tsx @@ -0,0 +1,26 @@ +import { sessionProgress } from '@/domain/session' +import type { Session } from '@/domain/session' + +// 면접 시작 직후, 첫 질문이 준비될 때까지 스테이지 진입 전에 머무는 대기 화면. +// 첫 질문이 도착하면 LiveInterview 가 InterviewStage 로 전환하며 화면이 "켜진다". +export function InterviewPreparing({ session }: { session: Session }) { + const progress = sessionProgress(session) + return ( +

+ + {[0, 1, 2].map((i) => ( + + ))} + +

{session.title ?? '모의 면접'}

+

+ 첫 질문을 만들고 있어요. 준비가 끝나면 바로 면접이 시작됩니다. +

+

총 {progress.max}개의 질문이 준비됩니다.

+
+ ) +} diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 9450562f..7bc4ed19 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -4,9 +4,11 @@ import { Button } from '@/shared/ui/Button' import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' +import { useDeliveryMode } from '../../model/useDeliveryMode' import { ConnectionBanner } from './ConnectionBanner' import { AnswerComposer } from './AnswerComposer' import { StageQuestion } from './StageQuestion' +import { DeliveryModeToggle } from './DeliveryModeToggle' import { InterviewerAvatar } from './InterviewerAvatar' import { WebcamSelfView } from './WebcamSelfView' import { TranscriptDrawer } from './TranscriptDrawer' @@ -67,13 +69,14 @@ export function InterviewStage({ wasSegmented: (id: number) => boolean }) { const [transcriptOpen, setTranscriptOpen] = useState(false) + const [deliveryMode, setDeliveryMode] = useDeliveryMode() const progress = sessionProgress(session) const currentQuestion = [...items].reverse().find(isQuestion) const lastItem = items[items.length - 1] const transcribing = Boolean(lastItem && isTranscribing(lastItem)) return ( -
+
+ {connLabel[connection]}
@@ -141,6 +145,7 @@ export function InterviewStage({ setTranscriptOpen(false)} /> )} diff --git a/frontend/src/features/interview/ui/live/LiveInterview.tsx b/frontend/src/features/interview/ui/live/LiveInterview.tsx index db3cfdca..2e37cefe 100644 --- a/frontend/src/features/interview/ui/live/LiveInterview.tsx +++ b/frontend/src/features/interview/ui/live/LiveInterview.tsx @@ -3,6 +3,7 @@ import { useLiveInterview } from '../../model/useLiveInterview' import { InterviewStage } from './InterviewStage' import { SessionEndedPanel } from './SessionEndedPanel' import { InterviewLobby } from './InterviewLobby' +import { InterviewPreparing } from './InterviewPreparing' export function LiveInterview({ sessionId }: { sessionId: number }) { const { @@ -18,11 +19,12 @@ export function LiveInterview({ sessionId }: { sessionId: number }) { isLoading, questionStreaming, wasSegmented, + firstQuestionReady, } = useLiveInterview(sessionId) if (isLoading || !session) { return ( -
+
) @@ -33,6 +35,10 @@ export function LiveInterview({ sessionId }: { sessionId: number }) { if (status !== 'IN_PROGRESS') { return } + // 면접은 시작됐지만 첫 질문이 아직 안 왔으면 스테이지 진입 전 대기 화면을 보여준다. + if (!firstQuestionReady) { + return + } const awaitingQuestion = turn === 'WAITING_FOR_QUESTION' return ( diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx index 8824ff3f..27de1546 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx @@ -16,7 +16,7 @@ export function SessionEndedPanel({ sessionId: number }) { return ( -
+

{messageByStatus[status] ?? '면접이 종료되었습니다.'}

{status === 'COMPLETED' && ( diff --git a/frontend/src/features/interview/ui/live/StageQuestion.tsx b/frontend/src/features/interview/ui/live/StageQuestion.tsx index 153b5bb6..62557193 100644 --- a/frontend/src/features/interview/ui/live/StageQuestion.tsx +++ b/frontend/src/features/interview/ui/live/StageQuestion.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react' import type { Message } from '@/domain/session' import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' import { useTypewriter } from '../../lib/useTypewriter' +import type { DeliveryMode } from '../../model/useDeliveryMode' function PlayIcon({ playing }: { playing: boolean }) { return ( @@ -11,17 +13,53 @@ function PlayIcon({ playing }: { playing: boolean }) { ) } +// 음성 재생/대기 상태를 나타내는 이퀄라이저. playing=false 면 정지된 막대. +function VoiceWave({ playing }: { playing: boolean }) { + const bars = [0, 1, 2, 3, 4] + return ( + + {bars.map((i) => ( + + ))} + + ) +} + // 면접관이 지금 막 던진 한 질문에만 집중시키는 카드. -export function StageQuestion({ question, segmented = false, streaming = false }: { question: Message; segmented?: boolean; streaming?: boolean }) { +export function StageQuestion({ + question, + segmented = false, + streaming = false, + mode = 'text', +}: { + question: Message + segmented?: boolean + streaming?: boolean + mode?: DeliveryMode +}) { const label = categoryLabel(question.category) - const ttsReady = question.ttsStatus === 'SUCCEEDED' + const ttsStatus = question.ttsStatus + const ttsReady = ttsStatus === 'SUCCEEDED' + const ttsPending = ttsStatus === 'PENDING' + const ttsFailed = ttsStatus === 'FAILED' + const voiceMode = mode === 'voice' const shownText = useTypewriter(question.content ?? '', !!streaming) + // 음성 모드여도 TTS 가 실패했으면 텍스트로 폴백한다. + const listenOnly = voiceMode && !ttsFailed + const [revealText, setRevealText] = useState(false) + const showText = !listenOnly || revealText + const { playing, toggle, audioNode } = useTtsPlayback({ sessionId: question.sessionId, messageId: question.id, + // 텍스트 모드에서는 절대 자동재생하지 않아 읽는 도중 끼어들지 않게 한다. enabled: ttsReady, - autoPlay: !segmented, + autoPlay: voiceMode && !segmented, }) return ( @@ -35,30 +73,87 @@ export function StageQuestion({ question, segmented = false, streaming = false }
면접관 - {label && ( - {label} - )} + {label && {label}}
-

- {shownText} -

+ {showText && ( +

+ {shownText} +

+ )} - {ttsReady && ( -
+ {/* 음성 모드: 듣기에 집중하도록 텍스트를 숨기고 재생 상태를 보여준다. */} + {listenOnly && ( +
+ {ttsReady ? ( + <> + + + + ) : ( +

+ + {[0, 1, 2].map((i) => ( + + ))} + + 음성을 준비하고 있어요… +

+ )} {audioNode}
)} + + {/* 텍스트 모드: 음성은 끼어들지 않고, 상태/수동 재생만 제공한다. */} + {!listenOnly && (ttsReady || ttsPending) && ( +
+ {ttsReady ? ( + + ) : ( + + + {[0, 1, 2].map((i) => ( + + ))} + + 음성 준비 중 + + )} + {audioNode} +
+ )}
) } diff --git a/frontend/src/features/interview/ui/live/TranscriptDrawer.tsx b/frontend/src/features/interview/ui/live/TranscriptDrawer.tsx index 1803b858..b17d5149 100644 --- a/frontend/src/features/interview/ui/live/TranscriptDrawer.tsx +++ b/frontend/src/features/interview/ui/live/TranscriptDrawer.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { ThreadItem } from '../../model/useLiveInterview' +import type { DeliveryMode } from '../../model/useDeliveryMode' import { ConversationThread } from './ConversationThread' // 몰입형 스테이지는 현재 질문에만 집중하므로, 지난 문답 전체는 @@ -7,10 +8,12 @@ import { ConversationThread } from './ConversationThread' export function TranscriptDrawer({ items, awaitingQuestion, + mode = 'text', onClose, }: { items: ThreadItem[] awaitingQuestion: boolean + mode?: DeliveryMode onClose: () => void }) { useEffect(() => { @@ -45,7 +48,7 @@ export function TranscriptDrawer({
- +
diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 47cabeb5..091bb50b 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -16,6 +16,7 @@ export function InterviewSetupForm({ onCreate: (req: SessionCreateRequest) => void isSubmitting?: boolean }) { + const [title, setTitle] = useState('') const [mode, setMode] = useState(null) const [jobCategories, setJobCategories] = useState([]) const [generalQuestionCount, setGeneralQuestionCount] = useState(3) @@ -36,7 +37,9 @@ export function InterviewSetupForm({ const submit = () => { if (mode === null || jobCategories.length === 0 || !valid) return + const trimmedTitle = title.trim() onCreate({ + title: trimmedTitle || undefined, mode, jobCategories, generalQuestionCount, @@ -54,6 +57,21 @@ export function InterviewSetupForm({ submit() }} > +
+ + setTitle(e.target.value)} + maxLength={60} + placeholder="예: 백엔드 기술 면접 2차" + className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none" + /> +

면접 모드

diff --git a/frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx b/frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx index 536fdfaa..0964168e 100644 --- a/frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx +++ b/frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx @@ -1,6 +1,4 @@ import { useParams } from 'react-router-dom' -import { SiteNav } from '@/widgets/site-nav' -import { SiteFooter } from '@/widgets/site-footer' import { LiveInterview } from '@/features/interview' export default function InterviewSessionPage() { @@ -8,20 +6,18 @@ export default function InterviewSessionPage() { const sessionId = Number(id) const valid = Number.isFinite(sessionId) && sessionId > 0 + if (!valid) { + return ( +
+

잘못된 세션입니다.

+
+ ) + } + + // 라이브 면접은 전역 헤더·푸터 없이 뷰포트 전체를 차지하는 몰입형 화면. return ( -
- -
- {valid ? ( - // 라이브 면접은 몰입형 스테이지 — 배경 위에 현재 질문 집중 + composer 하단 고정. -
- -
- ) : ( -

잘못된 세션입니다.

- )} -
- +
+
) } diff --git a/frontend/src/widgets/home-faq/ui/HomeFaq.tsx b/frontend/src/widgets/home-faq/ui/HomeFaq.tsx index ab517cff..618a7024 100644 --- a/frontend/src/widgets/home-faq/ui/HomeFaq.tsx +++ b/frontend/src/widgets/home-faq/ui/HomeFaq.tsx @@ -1,7 +1,7 @@ const faqs = [ { q: 'Stack Up은 무료 크레딧을 제공하나요?', - a: '월 2회의 무료 면접 세션을 제공합니다. 학교 종합설계 프로젝트 기준이라 결제·구독 기능은 포함되지 않아요.', + a: '월 2회의 무료 면접 세션을 제공할 예정입니다. 학교 종합설계 프로젝트 기준이라 결제·구독 기능은 포함되지 않아요.', }, { q: '어떤 형식의 이력서를 지원하나요?', @@ -9,15 +9,15 @@ const faqs = [ }, { q: 'GitHub 외 다른 로그인은 가능한가요?', - a: '레포 분석이 핵심 기능이라, Phase 1에서는 GitHub OAuth만 제공합니다.', + a: '개발자를 위한 서비스이고 레포 분석이 핵심 기능이라, MVP단계에선 GitHub OAuth만 제공합니다.', }, { q: '꼬리질문은 얼마나 빨리 받을 수 있나요?', a: '평균 3초 이내 응답을 목표로 합니다. Flash 모델과 사전 구축 RAG 인덱스로 지연을 최소화해요.', }, { - q: '음성·비언어 분석은 어떻게 동작하나요?', - a: 'WebRTC로 마이크·웹캠 스트림을 받아 말 속도(WPM)·간투어·시선·자세를 분석합니다. 권한을 거부하면 텍스트 입력으로 진행할 수 있어요.', + q: '음성 분석은 어떻게 동작하나요?', + a: 'WebRTC로 마이크·웹캠 스트림을 받아 말 속도(WPM)·간투어를 분석합니다. 권한을 거부하면 텍스트 입력으로 진행할 수 있어요.', }, ]