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
41 changes: 41 additions & 0 deletions frontend/src/app/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/features/interview/model/useDeliveryMode.ts
Original file line number Diff line number Diff line change
@@ -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<DeliveryMode>(readStored)

const update = useCallback((next: DeliveryMode) => {
setMode(next)
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
// 저장 실패는 무시 — 세션 내 상태는 유지된다.
}
}, [])

return [mode, update]
}
9 changes: 9 additions & 0 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -181,5 +189,6 @@ export function useLiveInterview(sessionId: number) {
isLoading: sessionQuery.isLoading,
questionStreaming,
wasSegmented,
firstQuestionReady,
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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'

export function ConversationThread({
items,
awaitingQuestion,
mode = 'text',
}: {
items: ThreadItem[]
awaitingQuestion: boolean
mode?: DeliveryMode
}) {
// 내부 스레드 컨테이너만 스크롤한다. scrollIntoView 는 스크롤 가능한 모든
// 조상(=window)까지 스크롤해 페이지가 푸터로 끌려 내려가므로 사용하지 않는다.
Expand All @@ -27,7 +30,7 @@ export function ConversationThread({
<div ref={containerRef} className="flex h-full flex-col gap-3 overflow-y-auto px-4 py-6">
{items.map((item) =>
isQuestion(item) ? (
<QuestionBubble key={item.key} message={item} autoPlay={item.key === lastQuestionKey} streaming={item.streaming} />
<QuestionBubble key={item.key} message={item} autoPlay={mode === 'voice' && item.key === lastQuestionKey} streaming={item.streaming} />
) : (
<AnswerBubble key={item.key} message={item} />
),
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ReactElement } from 'react'
import type { DeliveryMode } from '../../model/useDeliveryMode'

function TextIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<path d="M4 7V5h16v2M9 5v14M9 19h6" />
</svg>
)
}

function VoiceIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
)
}

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 (
<div
role="radiogroup"
aria-label="면접 진행 방식"
className="inline-flex items-center gap-0.5 rounded-pill border border-white/50 bg-white/55 p-0.5 backdrop-blur-md"
>
{options.map(({ value: v, label, icon: Icon }) => {
const active = value === v
return (
<button
key={v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(v)}
className={[
'inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-caption font-medium transition-colors',
active
? 'bg-sage-800 text-white shadow-sm'
: 'text-fg-muted hover:text-fg',
].join(' ')}
>
<Icon />
<span className="hidden sm:inline">{label}</span>
</button>
)
})}
</div>
)
}
2 changes: 1 addition & 1 deletion frontend/src/features/interview/ui/live/InterviewLobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function InterviewLobby({ sessionId, session }: { sessionId: number; sess
const { start } = useSessionLifecycle(sessionId)
const progress = sessionProgress(session)
return (
<div className="mx-auto flex max-w-readable flex-col items-center gap-6 px-4 py-16 text-center">
<div className="mx-auto flex h-full max-w-readable flex-col items-center justify-center gap-6 px-4 text-center">
<h1 className="text-h4 text-fg">{session.title ?? '모의 면접'}</h1>
<p className="text-body text-fg-muted">
총 {progress.max}개의 질문이 준비됩니다. 시작하면 첫 질문이 곧 도착합니다.
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewPreparing.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex h-full max-w-readable flex-col items-center justify-center gap-6 px-4 text-center">
<span className="flex gap-2" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-3 w-3 animate-pulse rounded-full bg-sage-700/70"
style={{ animationDelay: `${i * 180}ms` }}
/>
))}
</span>
<h1 className="text-h4 text-fg">{session.title ?? '모의 면접'}</h1>
<p className="text-body text-fg-muted" role="status">
첫 질문을 만들고 있어요. 준비가 끝나면 바로 면접이 시작됩니다.
</p>
<p className="text-caption text-fg-subtle">총 {progress.max}개의 질문이 준비됩니다.</p>
</div>
)
}
9 changes: 7 additions & 2 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<section className="relative flex h-full flex-col overflow-hidden">
<section className="anim-screen-power-on relative flex h-full flex-col overflow-hidden">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center"
Expand Down Expand Up @@ -102,6 +105,7 @@ export function InterviewStage({
</p>
</div>
<div className="flex items-center gap-2">
<DeliveryModeToggle value={deliveryMode} onChange={setDeliveryMode} />
<StatusBadge tone={connTone[connection]}>{connLabel[connection]}</StatusBadge>
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
기록
Expand All @@ -119,7 +123,7 @@ export function InterviewStage({
{awaitingQuestion || !currentQuestion ? (
<ThinkingState transcribing={transcribing} />
) : (
<StageQuestion question={currentQuestion} segmented={wasSegmented(currentQuestion.id ?? -1)} streaming={currentQuestion?.streaming ?? false} />
<StageQuestion question={currentQuestion} segmented={wasSegmented(currentQuestion.id ?? -1)} streaming={currentQuestion?.streaming ?? false} mode={deliveryMode} />
)}
</div>

Expand All @@ -141,6 +145,7 @@ export function InterviewStage({
<TranscriptDrawer
items={items}
awaitingQuestion={awaitingQuestion}
mode={deliveryMode}
onClose={() => setTranscriptOpen(false)}
/>
)}
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/features/interview/ui/live/LiveInterview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,11 +19,12 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
isLoading,
questionStreaming,
wasSegmented,
firstQuestionReady,
} = useLiveInterview(sessionId)

if (isLoading || !session) {
return (
<div className="flex justify-center py-16">
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
)
Expand All @@ -33,6 +35,10 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
if (status !== 'IN_PROGRESS') {
return <SessionEndedPanel status={status ?? 'COMPLETED'} sessionId={sessionId} />
}
// 면접은 시작됐지만 첫 질문이 아직 안 왔으면 스테이지 진입 전 대기 화면을 보여준다.
if (!firstQuestionReady) {
return <InterviewPreparing session={session} />
}

const awaitingQuestion = turn === 'WAITING_FOR_QUESTION'
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function SessionEndedPanel({
sessionId: number
}) {
return (
<div className="flex flex-col items-center gap-4 px-4 py-16 text-center">
<div className="flex h-full flex-col items-center justify-center gap-4 px-4 text-center">
<p className="text-rich text-fg">{messageByStatus[status] ?? '면접이 종료되었습니다.'}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{status === 'COMPLETED' && (
Expand Down
Loading