Skip to content

Commit 2cfb034

Browse files
Jaeho-Siteclaude
andcommitted
feat(interview): 라이브 면접을 몰입형 스테이지 UI로 재설계
채팅 로그 누적 방식 대신 배경 위에 현재 질문 하나에 집중하는 스테이지로 전환. 진행도 바·연결 상태·종료를 상단에 배치하고, 지난 문답 전체는 '기록' 드로어에서만 확인한다. 답변 음성/텍스트 컴포저는 그대로 재사용. 연결·데이터 로직은 변경 없음. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 81e8824 commit 2cfb034

7 files changed

Lines changed: 263 additions & 63 deletions

File tree

1.76 MB
Loading

frontend/src/features/interview/ui/live/InterviewHeader.tsx

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useState } from 'react'
2+
import { StatusBadge } from '@/shared/ui/StatusBadge'
3+
import { Button } from '@/shared/ui/Button'
4+
import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session'
5+
import type { Session } from '@/domain/session'
6+
import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview'
7+
import { ConnectionBanner } from './ConnectionBanner'
8+
import { AnswerComposer } from './AnswerComposer'
9+
import { StageQuestion } from './StageQuestion'
10+
import { TranscriptDrawer } from './TranscriptDrawer'
11+
12+
const BG = '/interview-session-background.png'
13+
14+
const connTone: Record<ConnectionStatus, 'success' | 'warning' | 'danger'> = {
15+
open: 'success',
16+
connecting: 'warning',
17+
closed: 'danger',
18+
}
19+
const connLabel: Record<ConnectionStatus, string> = {
20+
open: '연결됨',
21+
connecting: '연결 중',
22+
closed: '재연결 중',
23+
}
24+
25+
function ThinkingState({ transcribing }: { transcribing: boolean }) {
26+
return (
27+
<div className="flex flex-col items-center gap-4 text-fg-muted">
28+
<span className="flex gap-1.5" aria-hidden>
29+
{[0, 1, 2].map((i) => (
30+
<span
31+
key={i}
32+
className="h-2.5 w-2.5 animate-pulse rounded-full bg-sage-700/70"
33+
style={{ animationDelay: `${i * 150}ms` }}
34+
/>
35+
))}
36+
</span>
37+
<p className="text-body font-medium text-fg">
38+
{transcribing ? '답변을 받아 적고 있어요…' : '다음 질문을 준비하고 있어요…'}
39+
</p>
40+
</div>
41+
)
42+
}
43+
44+
export function InterviewStage({
45+
session,
46+
connection,
47+
items,
48+
awaitingQuestion,
49+
onSubmit,
50+
onSubmitVoice,
51+
voiceUploading,
52+
onEnd,
53+
}: {
54+
session: Session
55+
connection: ConnectionStatus
56+
items: ThreadItem[]
57+
awaitingQuestion: boolean
58+
onSubmit: (content: string) => void
59+
onSubmitVoice: (audio: Blob) => void
60+
voiceUploading: boolean
61+
onEnd: () => void
62+
}) {
63+
const [transcriptOpen, setTranscriptOpen] = useState(false)
64+
const progress = sessionProgress(session)
65+
const currentQuestion = [...items].reverse().find(isQuestion)
66+
const lastItem = items[items.length - 1]
67+
const transcribing = Boolean(lastItem && isTranscribing(lastItem))
68+
69+
return (
70+
<section className="relative flex h-full flex-col overflow-hidden">
71+
<div
72+
aria-hidden
73+
className="absolute inset-0 bg-cover bg-center"
74+
style={{ backgroundImage: `url(${BG})` }}
75+
/>
76+
<div
77+
aria-hidden
78+
className="absolute inset-0 bg-gradient-to-b from-white/55 via-white/25 to-white/75"
79+
/>
80+
81+
{/* 진행도 바 */}
82+
<div className="relative z-10 h-1 w-full bg-white/40">
83+
<div
84+
className="h-full bg-primary transition-[width] duration-slow ease-standard"
85+
style={{ width: `${progress.ratio * 100}%` }}
86+
/>
87+
</div>
88+
89+
<header className="relative z-10 flex items-center justify-between gap-3 border-b border-white/40 bg-white/55 px-4 py-3 backdrop-blur-md">
90+
<div className="min-w-0">
91+
<h1 className="truncate text-h6 text-fg">{session.title ?? '모의 면접'}</h1>
92+
<p className="text-caption text-fg-muted">
93+
질문 {progress.current} / {progress.max}
94+
</p>
95+
</div>
96+
<div className="flex items-center gap-2">
97+
<StatusBadge tone={connTone[connection]}>{connLabel[connection]}</StatusBadge>
98+
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
99+
기록
100+
</Button>
101+
<Button variant="danger" size="sm" onClick={onEnd}>
102+
종료
103+
</Button>
104+
</div>
105+
</header>
106+
107+
<ConnectionBanner connection={connection} />
108+
109+
<div className="relative z-10 flex flex-1 items-center justify-center overflow-y-auto px-5 py-8">
110+
{awaitingQuestion || !currentQuestion ? (
111+
<ThinkingState transcribing={transcribing} />
112+
) : (
113+
<StageQuestion question={currentQuestion} />
114+
)}
115+
</div>
116+
117+
<div className="relative z-10">
118+
<AnswerComposer
119+
disabled={awaitingQuestion || connection !== 'open'}
120+
onSubmit={onSubmit}
121+
onSubmitVoice={onSubmitVoice}
122+
voiceUploading={voiceUploading}
123+
/>
124+
</div>
125+
126+
{transcriptOpen && (
127+
<TranscriptDrawer
128+
items={items}
129+
awaitingQuestion={awaitingQuestion}
130+
onClose={() => setTranscriptOpen(false)}
131+
/>
132+
)}
133+
</section>
134+
)
135+
}

frontend/src/features/interview/ui/live/LiveInterview.tsx

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { Spinner } from '@/shared/ui/Spinner'
22
import { useLiveInterview } from '../../model/useLiveInterview'
3-
import { InterviewHeader } from './InterviewHeader'
4-
import { ConnectionBanner } from './ConnectionBanner'
5-
import { ConversationThread } from './ConversationThread'
6-
import { AnswerComposer } from './AnswerComposer'
3+
import { InterviewStage } from './InterviewStage'
74
import { SessionEndedPanel } from './SessionEndedPanel'
85
import { InterviewLobby } from './InterviewLobby'
96

@@ -37,18 +34,15 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
3734

3835
const awaitingQuestion = turn === 'WAITING_FOR_QUESTION'
3936
return (
40-
<div className="flex h-full flex-col">
41-
<InterviewHeader session={session} connection={connection} onEnd={endSession} />
42-
<ConnectionBanner connection={connection} />
43-
<div className="min-h-0 flex-1">
44-
<ConversationThread items={items} awaitingQuestion={awaitingQuestion} />
45-
</div>
46-
<AnswerComposer
47-
disabled={turn !== 'AWAITING_ANSWER' || connection !== 'open'}
48-
onSubmit={submitAnswer}
49-
onSubmitVoice={submitVoice}
50-
voiceUploading={voiceUploading}
51-
/>
52-
</div>
37+
<InterviewStage
38+
session={session}
39+
connection={connection}
40+
items={items}
41+
awaitingQuestion={awaitingQuestion}
42+
onSubmit={submitAnswer}
43+
onSubmitVoice={submitVoice}
44+
voiceUploading={voiceUploading}
45+
onEnd={endSession}
46+
/>
5347
)
5448
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Message } from '@/domain/session'
2+
import { categoryLabel } from '../../lib/categoryLabel'
3+
import { useTtsPlayback } from '../../lib/media/useTtsPlayback'
4+
5+
function PlayIcon({ playing }: { playing: boolean }) {
6+
return (
7+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
8+
{playing ? <path d="M8 6h3v12H8zM13 6h3v12h-3z" /> : <path d="M8 5v14l11-7z" />}
9+
</svg>
10+
)
11+
}
12+
13+
// 면접관이 지금 막 던진 한 질문에만 집중시키는 카드.
14+
export function StageQuestion({ question }: { question: Message }) {
15+
const label = categoryLabel(question.category)
16+
const ttsReady = question.ttsStatus === 'SUCCEEDED'
17+
18+
const { playing, toggle, audioNode } = useTtsPlayback({
19+
sessionId: question.sessionId,
20+
messageId: question.id,
21+
enabled: ttsReady,
22+
autoPlay: true,
23+
})
24+
25+
return (
26+
<div className="w-full max-w-2xl rounded-2xl border border-white/50 bg-white/70 px-6 py-7 shadow-lg backdrop-blur-md sm:px-9 sm:py-10">
27+
<div className="flex items-center gap-2.5">
28+
<span
29+
aria-hidden
30+
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-sage-800 text-[11px] font-semibold uppercase tracking-wide text-white"
31+
>
32+
AI
33+
</span>
34+
<div className="flex flex-col">
35+
<span className="text-caption font-medium text-fg">면접관</span>
36+
{label && (
37+
<span className="text-caption text-fg-muted">{label}</span>
38+
)}
39+
</div>
40+
</div>
41+
42+
<p className="mt-5 whitespace-pre-wrap text-[22px] font-medium leading-relaxed text-fg sm:text-[26px]">
43+
{question.content}
44+
</p>
45+
46+
{ttsReady && (
47+
<div className="mt-6 flex items-center gap-2">
48+
<button
49+
type="button"
50+
onClick={toggle}
51+
aria-label={playing ? '음성 일시정지' : '질문 음성 재생'}
52+
className="inline-flex items-center gap-1.5 rounded-pill border border-border bg-white/70 px-3 py-1.5 text-caption font-medium text-fg transition-colors hover:bg-white"
53+
>
54+
<PlayIcon playing={playing} />
55+
{playing ? '일시정지' : '질문 다시 듣기'}
56+
</button>
57+
{audioNode}
58+
</div>
59+
)}
60+
</div>
61+
)
62+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useEffect } from 'react'
2+
import type { ThreadItem } from '../../model/useLiveInterview'
3+
import { ConversationThread } from './ConversationThread'
4+
5+
// 몰입형 스테이지는 현재 질문에만 집중하므로, 지난 문답 전체는
6+
// 필요할 때만 이 드로어에서 확인한다(채팅 스레드 재사용).
7+
export function TranscriptDrawer({
8+
items,
9+
awaitingQuestion,
10+
onClose,
11+
}: {
12+
items: ThreadItem[]
13+
awaitingQuestion: boolean
14+
onClose: () => void
15+
}) {
16+
useEffect(() => {
17+
const onKey = (e: KeyboardEvent) => {
18+
if (e.key === 'Escape') onClose()
19+
}
20+
window.addEventListener('keydown', onKey)
21+
return () => window.removeEventListener('keydown', onKey)
22+
}, [onClose])
23+
24+
return (
25+
<div className="absolute inset-0 z-30 flex">
26+
<div
27+
aria-hidden
28+
className="absolute inset-0 bg-black/30"
29+
onClick={onClose}
30+
/>
31+
<aside
32+
role="dialog"
33+
aria-label="대화 기록"
34+
className="relative ml-auto flex h-full w-full max-w-md flex-col bg-bg shadow-xl"
35+
>
36+
<header className="flex items-center justify-between border-b border-border px-4 py-3">
37+
<h2 className="text-h6 text-fg">대화 기록</h2>
38+
<button
39+
type="button"
40+
onClick={onClose}
41+
aria-label="닫기"
42+
className="rounded-md px-2 py-1 text-fg-muted transition-colors hover:bg-surface hover:text-fg"
43+
>
44+
45+
</button>
46+
</header>
47+
<div className="min-h-0 flex-1">
48+
<ConversationThread items={items} awaitingQuestion={awaitingQuestion} />
49+
</div>
50+
</aside>
51+
</div>
52+
)
53+
}

frontend/src/pages/InterviewSession/ui/InterviewSessionPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export default function InterviewSessionPage() {
1313
<SiteNav />
1414
<main className="mx-auto w-full max-w-content flex-1 px-6 py-6 lg:px-12">
1515
{valid ? (
16-
// 라이브 채팅은 고정 높이 카드로 — 내부에서 thread 스크롤 + composer 하단 고정.
17-
<div className="flex h-[70svh] min-h-120 flex-col overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm">
16+
// 라이브 면접은 몰입형 스테이지 — 배경 위에 현재 질문 집중 + composer 하단 고정.
17+
<div className="flex h-[78svh] min-h-120 flex-col overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm">
1818
<LiveInterview sessionId={sessionId} />
1919
</div>
2020
) : (

0 commit comments

Comments
 (0)