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
Binary file added frontend/public/interview-session-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions frontend/src/features/interview/lib/categoryLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 면접 질문 카테고리 ENUM → 한국어 라벨. 라이브 스테이지와 채팅 버블이 공유한다.
const CATEGORY_LABEL: Record<string, string> = {
CS_FUNDAMENTAL: 'CS 기초',
PROJECT_DEEP_DIVE: '프로젝트 심화',
TECH_CHOICE: '기술 선택',
BEHAVIORAL: '인성·행동',
}

export function categoryLabel(category?: string | null): string | null {
if (!category) return null
return CATEGORY_LABEL[category] ?? category
}
65 changes: 65 additions & 0 deletions frontend/src/features/interview/lib/media/useTtsPlayback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMessageAudio } from './useMessageAudio'

// 질문 TTS 오디오의 로드·자동재생·토글을 캡슐화한다.
// QuestionBubble(채팅)과 StageQuestion(몰입형 스테이지)이 공유한다.
// audioNode 를 렌더 트리에 그대로 끼워 넣으면 된다.
export function useTtsPlayback({
sessionId,
messageId,
enabled,
autoPlay = false,
}: {
sessionId?: number
messageId?: number
enabled: boolean
autoPlay?: boolean
}) {
const { url, load } = useMessageAudio(sessionId, messageId)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playing, setPlaying] = useState(false)
const wantPlay = useRef(false)
const autoTried = useRef(false)

// 최신 질문이면 오디오를 받아 자동재생 시도(브라우저 차단 시 조용히 폴백).
useEffect(() => {
if (!autoPlay || !enabled || autoTried.current) return
autoTried.current = true
wantPlay.current = true
void load()
}, [autoPlay, enabled, load])

// object URL 이 준비되면 대기 중이던 재생 요청을 반영.
useEffect(() => {
if (url && wantPlay.current) {
wantPlay.current = false
audioRef.current?.play().catch(() => {})
}
}, [url])

const toggle = useCallback(async () => {
const el = audioRef.current
if (!url) {
wantPlay.current = true
await load()
return
}
if (!el) return
if (el.paused) el.play().catch(() => {})
else el.pause()
}, [url, load])

const audioNode =
enabled && url ? (
<audio
ref={audioRef}
src={url}
preload="none"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
/>
) : null

return { playing, toggle, audioNode }
}
44 changes: 0 additions & 44 deletions frontend/src/features/interview/ui/live/InterviewHeader.tsx

This file was deleted.

135 changes: 135 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { StatusBadge } from '@/shared/ui/StatusBadge'
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 { ConnectionBanner } from './ConnectionBanner'
import { AnswerComposer } from './AnswerComposer'
import { StageQuestion } from './StageQuestion'
import { TranscriptDrawer } from './TranscriptDrawer'

const BG = '/interview-session-background.png'

const connTone: Record<ConnectionStatus, 'success' | 'warning' | 'danger'> = {
open: 'success',
connecting: 'warning',
closed: 'danger',
}
const connLabel: Record<ConnectionStatus, string> = {
open: '연결됨',
connecting: '연결 중',
closed: '재연결 중',
}

function ThinkingState({ transcribing }: { transcribing: boolean }) {
return (
<div className="flex flex-col items-center gap-4 text-fg-muted">
<span className="flex gap-1.5" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-2.5 w-2.5 animate-pulse rounded-full bg-sage-700/70"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
<p className="text-body font-medium text-fg">
{transcribing ? '답변을 받아 적고 있어요…' : '다음 질문을 준비하고 있어요…'}
</p>
</div>
)
}

export function InterviewStage({
session,
connection,
items,
awaitingQuestion,
onSubmit,
onSubmitVoice,
voiceUploading,
onEnd,
}: {
session: Session
connection: ConnectionStatus
items: ThreadItem[]
awaitingQuestion: boolean
onSubmit: (content: string) => void
onSubmitVoice: (audio: Blob) => void
voiceUploading: boolean
onEnd: () => void
}) {
const [transcriptOpen, setTranscriptOpen] = useState(false)
const progress = sessionProgress(session)
const currentQuestion = [...items].reverse().find(isQuestion)
const lastItem = items[items.length - 1]
const transcribing = Boolean(lastItem && isTranscribing(lastItem))

return (
<section className="relative flex h-full flex-col overflow-hidden">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${BG})` }}
/>
<div
aria-hidden
className="absolute inset-0 bg-gradient-to-b from-white/55 via-white/25 to-white/75"
/>

{/* 진행도 바 */}
<div className="relative z-10 h-1 w-full bg-white/40">
<div
className="h-full bg-primary transition-[width] duration-slow ease-standard"
style={{ width: `${progress.ratio * 100}%` }}
/>
</div>

<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">
<div className="min-w-0">
<h1 className="truncate text-h6 text-fg">{session.title ?? '모의 면접'}</h1>
<p className="text-caption text-fg-muted">
질문 {progress.current} / {progress.max}
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge tone={connTone[connection]}>{connLabel[connection]}</StatusBadge>
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
기록
</Button>
<Button variant="danger" size="sm" onClick={onEnd}>
종료
</Button>
</div>
</header>

<ConnectionBanner connection={connection} />

<div className="relative z-10 flex flex-1 items-center justify-center overflow-y-auto px-5 py-8">
{awaitingQuestion || !currentQuestion ? (
<ThinkingState transcribing={transcribing} />
) : (
<StageQuestion question={currentQuestion} />
)}
</div>

<div className="relative z-10">
<AnswerComposer
disabled={awaitingQuestion || connection !== 'open'}
onSubmit={onSubmit}
onSubmitVoice={onSubmitVoice}
voiceUploading={voiceUploading}
/>
</div>

{transcriptOpen && (
<TranscriptDrawer
items={items}
awaitingQuestion={awaitingQuestion}
onClose={() => setTranscriptOpen(false)}
/>
)}
</section>
)
}
28 changes: 11 additions & 17 deletions frontend/src/features/interview/ui/live/LiveInterview.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Spinner } from '@/shared/ui/Spinner'
import { useLiveInterview } from '../../model/useLiveInterview'
import { InterviewHeader } from './InterviewHeader'
import { ConnectionBanner } from './ConnectionBanner'
import { ConversationThread } from './ConversationThread'
import { AnswerComposer } from './AnswerComposer'
import { InterviewStage } from './InterviewStage'
import { SessionEndedPanel } from './SessionEndedPanel'
import { InterviewLobby } from './InterviewLobby'

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

const awaitingQuestion = turn === 'WAITING_FOR_QUESTION'
return (
<div className="flex h-full flex-col">
<InterviewHeader session={session} connection={connection} onEnd={endSession} />
<ConnectionBanner connection={connection} />
<div className="min-h-0 flex-1">
<ConversationThread items={items} awaitingQuestion={awaitingQuestion} />
</div>
<AnswerComposer
disabled={turn !== 'AWAITING_ANSWER' || connection !== 'open'}
onSubmit={submitAnswer}
onSubmitVoice={submitVoice}
voiceUploading={voiceUploading}
/>
</div>
<InterviewStage
session={session}
connection={connection}
items={items}
awaitingQuestion={awaitingQuestion}
onSubmit={submitAnswer}
onSubmitVoice={submitVoice}
voiceUploading={voiceUploading}
onEnd={endSession}
/>
)
}
69 changes: 12 additions & 57 deletions frontend/src/features/interview/ui/live/QuestionBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { Message } from '@/domain/session'
import { StatusBadge } from '@/shared/ui/StatusBadge'
import { useMessageAudio } from '../../lib/media/useMessageAudio'

const CATEGORY_LABEL: Record<string, string> = {
CS_FUNDAMENTAL: 'CS 기초',
PROJECT_DEEP_DIVE: '프로젝트 심화',
TECH_CHOICE: '기술 선택',
BEHAVIORAL: '인성·행동',
}
import { categoryLabel } from '../../lib/categoryLabel'
import { useTtsPlayback } from '../../lib/media/useTtsPlayback'

function PlayIcon({ playing }: { playing: boolean }) {
return (
Expand All @@ -25,52 +18,23 @@ export function QuestionBubble({
message: Message
autoPlay?: boolean
}) {
const categoryLabel = message.category
? (CATEGORY_LABEL[message.category] ?? message.category)
: null
const hasMeta = Boolean(categoryLabel || message.targetEvidence)
const label = categoryLabel(message.category)
const hasMeta = Boolean(label || message.targetEvidence)
const ttsReady = message.ttsStatus === 'SUCCEEDED'

const { url, load } = useMessageAudio(message.sessionId, message.id)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playing, setPlaying] = useState(false)
const wantPlay = useRef(false)
const autoTried = useRef(false)

// 최신 질문이면 오디오를 받아 자동재생 시도(차단 시 조용히 폴백).
useEffect(() => {
if (!autoPlay || !ttsReady || autoTried.current) return
autoTried.current = true
wantPlay.current = true
void load()
}, [autoPlay, ttsReady, load])

// object URL 이 준비되면 재생 요청을 반영.
useEffect(() => {
if (url && wantPlay.current) {
wantPlay.current = false
audioRef.current?.play().catch(() => {})
}
}, [url])

const toggle = async () => {
const el = audioRef.current
if (!url) {
wantPlay.current = true
await load()
return
}
if (!el) return
if (el.paused) el.play().catch(() => {})
else el.pause()
}
const { playing, toggle, audioNode } = useTtsPlayback({
sessionId: message.sessionId,
messageId: message.id,
enabled: ttsReady,
autoPlay,
})

return (
<div className="flex justify-start">
<div className="flex max-w-[80%] flex-col gap-1.5">
{hasMeta && (
<div className="flex flex-wrap items-center gap-2">
{categoryLabel && <StatusBadge tone="info">{categoryLabel}</StatusBadge>}
{label && <StatusBadge tone="info">{label}</StatusBadge>}
{message.targetEvidence && (
<span className="text-caption text-fg-muted">근거: {message.targetEvidence}</span>
)}
Expand All @@ -89,16 +53,7 @@ export function QuestionBubble({
<PlayIcon playing={playing} />
{playing ? '일시정지' : '음성 듣기'}
</button>
{url && (
<audio
ref={audioRef}
src={url}
preload="none"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
/>
)}
{audioNode}
</div>
)}
</div>
Expand Down
Loading