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
4 changes: 3 additions & 1 deletion frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview'
import { ConnectionBanner } from './ConnectionBanner'
import { AnswerComposer } from './AnswerComposer'
import { StageQuestion } from './StageQuestion'
import { InterviewerAvatar } from './InterviewerAvatar'
import { TranscriptDrawer } from './TranscriptDrawer'

const BG = '/interview-session-background.png'
Expand Down Expand Up @@ -108,7 +109,8 @@ export function InterviewStage({

<ConnectionBanner connection={connection} />

<div className="relative z-10 flex flex-1 items-center justify-center overflow-y-auto px-5 py-8">
<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-6 overflow-y-auto px-5 py-8">
<InterviewerAvatar state={awaitingQuestion || !currentQuestion ? 'thinking' : 'asking'} />
{awaitingQuestion || !currentQuestion ? (
<ThinkingState transcribing={transcribing} />
) : (
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewerAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { InterviewerAvatar } from './InterviewerAvatar'

describe('InterviewerAvatar', () => {
it('기본적으로 면접관 이미지를 렌더한다', () => {
render(<InterviewerAvatar state="idle" />)
expect(screen.getByAltText('면접관')).toBeInTheDocument()
})

it('이미지 로드 실패 시 사람 아이콘 폴백을 보여준다', () => {
render(<InterviewerAvatar state="idle" />)
fireEvent.error(screen.getByAltText('면접관'))
expect(screen.queryByAltText('면접관')).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: '면접관' })).toBeInTheDocument()
})
})
51 changes: 51 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewerAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useState } from 'react'

export type InterviewerState = 'idle' | 'thinking' | 'asking'

// public/interviewer.png 가 있으면 사용, 없으면 onError 로 사람 아이콘 폴백.
const SRC = '/interviewer.png'

function FallbackFace() {
return (
<span role="img" aria-label="면접관" className="flex h-full w-full items-center justify-center">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className="h-1/2 w-1/2">
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.69-8 6v1h16v-1c0-3.31-3.58-6-8-6Z" />
</svg>
</span>
)
}

// 라이브 면접 스테이지 상단에서 "사람이 묻는" 존재감을 주는 면접관 아바타.
// 캡션(면접관/카테고리)은 아래 질문 카드가 이미 보여주므로 여기서는 시각 요소만 담당한다.
export function InterviewerAvatar({ state }: { state: InterviewerState }) {
const [imgFailed, setImgFailed] = useState(false)

return (
<div className="relative flex items-center justify-center">
{state === 'thinking' && (
<span
aria-hidden
className="absolute -inset-1 animate-ping rounded-full border-2 border-sage-400/40"
/>
)}
<div
className={[
'relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full',
'bg-sage-800 text-white shadow-lg ring-4 transition-colors duration-slow sm:h-24 sm:w-24',
state === 'asking' ? 'ring-primary/60' : 'ring-white/50',
].join(' ')}
>
{imgFailed ? (
<FallbackFace />
) : (
<img
src={SRC}
alt="면접관"
className="h-full w-full object-cover"
onError={() => setImgFailed(true)}
/>
)}
</div>
</div>
)
}