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
17 changes: 17 additions & 0 deletions frontend/src/app/router/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect } from 'react'
import { Outlet, useLocation } from 'react-router-dom'

// createBrowserRouter 는 라우트 이동 시 스크롤을 자동으로 초기화하지 않는다.
// 푸터 등 페이지 하단 링크로 이동하면 직전 스크롤 위치가 그대로 유지돼
// 새 페이지에서도 하단(푸터)을 보게 되므로, 경로 변경 시 상단으로 올린다.
// 해시 앵커(/#services 등)는 각 페이지가 직접 스크롤하므로 건드리지 않는다.
export function ScrollToTop() {
const { pathname, hash } = useLocation()

useEffect(() => {
if (hash) return
window.scrollTo({ top: 0 })
}, [pathname, hash])

return <Outlet />
}
140 changes: 73 additions & 67 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { RequireAuth } from '@/features/auth'
import { ScrollToTop } from './ScrollToTop'
import HomePage from '@/pages/Home'
import LoginPage from '@/pages/Login'
import AuthCallbackPage from '@/pages/AuthCallback'
Expand All @@ -11,73 +12,78 @@ import SessionFeedbackPage from '@/pages/SessionFeedback'
import SharedFeedbackPage from '@/pages/SharedFeedback'

export const router = createBrowserRouter([
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/practice/:track', element: <PracticePage /> },
{ path: '/share/:token', element: <SharedFeedbackPage /> },
{ path: '/auth/callback', element: <AuthCallbackPage /> },
{
path: '/workspace',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/workspace/resumes',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/workspace/repos',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/sessions/new',
element: (
<RequireAuth>
<InterviewSetupPage />
</RequireAuth>
),
},
{
path: '/sessions/:id',
element: (
<RequireAuth>
<InterviewSessionPage />
</RequireAuth>
),
},
{
path: '/sessions/:id/feedback',
element: (
<RequireAuth>
<SessionFeedbackPage />
</RequireAuth>
),
},
{
path: '/workspace/history',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{ path: '/history', element: <Navigate to="/workspace/history" replace /> },
{
path: '/design-system/*',
lazy: async () => {
const mod = await import('@/pages/DesignSystem')
return { Component: mod.default }
},
element: <ScrollToTop />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/practice/:track', element: <PracticePage /> },
{ path: '/share/:token', element: <SharedFeedbackPage /> },
{ path: '/auth/callback', element: <AuthCallbackPage /> },
{
path: '/workspace',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/workspace/resumes',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/workspace/repos',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/sessions/new',
element: (
<RequireAuth>
<InterviewSetupPage />
</RequireAuth>
),
},
{
path: '/sessions/:id',
element: (
<RequireAuth>
<InterviewSessionPage />
</RequireAuth>
),
},
{
path: '/sessions/:id/feedback',
element: (
<RequireAuth>
<SessionFeedbackPage />
</RequireAuth>
),
},
{
path: '/workspace/history',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{ path: '/history', element: <Navigate to="/workspace/history" replace /> },
{
path: '/design-system/*',
lazy: async () => {
const mod = await import('@/pages/DesignSystem')
return { Component: mod.default }
},
},
],
},
])
10 changes: 6 additions & 4 deletions frontend/src/features/interview/ui/live/ConversationThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ export function ConversationThread({
items: ThreadItem[]
awaitingQuestion: boolean
}) {
const bottomRef = useRef<HTMLDivElement>(null)
// 내부 스레드 컨테이너만 스크롤한다. scrollIntoView 는 스크롤 가능한 모든
// 조상(=window)까지 스크롤해 페이지가 푸터로 끌려 내려가므로 사용하지 않는다.
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
const el = containerRef.current
if (el) el.scrollTop = el.scrollHeight
}, [items.length, awaitingQuestion])

// 가장 마지막 질문만 자동재생(초기 로드 시 과거 질문 일괄 재생 방지).
const lastQuestionKey = [...items].reverse().find(isQuestion)?.key

return (
<div className="flex h-full flex-col gap-3 overflow-y-auto px-4 py-6">
<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} />
Expand All @@ -30,7 +33,6 @@ export function ConversationThread({
),
)}
{awaitingQuestion ? <TypingIndicator /> : null}
<div ref={bottomRef} />
</div>
)
}