Skip to content
12 changes: 9 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fira+Sans+Extra+Condensed:wght@400;500;600;700;800;900&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap"
as="style"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<title>StackUp</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;600;700&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Geist+Mono:wght@400;500;600&display=swap"
/>
<title>StackUp — IT Interview Solution</title>
</head>
<body>
<div id="root"></div>
Expand Down
19 changes: 0 additions & 19 deletions frontend/src/App.tsx

This file was deleted.

27 changes: 27 additions & 0 deletions frontend/src/app/providers/AppProviders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Suspense } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/features/auth'
import { router } from '@/app/router'

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
})

export function AppProviders() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Suspense fallback={null}>
<RouterProvider router={router} />
</Suspense>
</AuthProvider>
</QueryClientProvider>
)
}
1 change: 1 addition & 0 deletions frontend/src/app/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppProviders } from './AppProviders'
Empty file removed frontend/src/app/router/index.ts
Empty file.
27 changes: 27 additions & 0 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createBrowserRouter } from 'react-router-dom'
import { RequireAuth } from '@/features/auth'
import HomePage from '@/pages/Home'
import LoginPage from '@/pages/Login'
import AuthCallbackPage from '@/pages/AuthCallback'
import WorkspacePage from '@/pages/Workspace'

export const router = createBrowserRouter([
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/auth/callback', element: <AuthCallbackPage /> },
{
path: '/workspace',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{
path: '/design-system/*',
lazy: async () => {
const mod = await import('@/pages/DesignSystem')
return { Component: mod.default }
},
},
])
1 change: 1 addition & 0 deletions frontend/src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { AuthProvider } from './model/AuthProvider'
export { useAuth } from './model/useAuth'
export { useLogout } from './model/useLogout'
export type { AuthContextValue, AuthStatus } from './model/AuthContext'
export { GithubLoginButton } from './ui/GithubLoginButton'
export { RequireAuth } from './ui/RequireAuth'
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/features/auth/model/useLogout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useCallback, useState } from 'react'
import { useAuth } from './useAuth'

export function useLogout() {
const { logout: logoutFromAuth } = useAuth()
const [loggingOut, setLoggingOut] = useState(false)

const logout = useCallback(async () => {
if (loggingOut) return
setLoggingOut(true)
try {
await logoutFromAuth()
} finally {
setLoggingOut(false)
}
}, [loggingOut, logoutFromAuth])

return { logout, loggingOut }
}
4 changes: 2 additions & 2 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/app/styles'
import App from './App.tsx'
import { AppProviders } from '@/app/providers'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AppProviders />
</StrictMode>,
)
1 change: 1 addition & 0 deletions frontend/src/pages/AuthCallback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ui/AuthCallbackPage'
99 changes: 99 additions & 0 deletions frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { completeGithubLogin, useAuth } from '@/features/auth'
import { consumeReturnTo } from '@/features/auth/lib/return-to'
import { isApiError } from '@/shared/api'

const consumedCodes = new Set<string>()

export default function AuthCallbackPage() {
const [params] = useSearchParams()
const navigate = useNavigate()
const { applyLogin } = useAuth()

const code = params.get('code')
const stateParam = params.get('state')
const missingParams = !code || !stateParam

const [error, setError] = useState<string | null>(
missingParams ? 'GitHub 응답에 code 또는 state가 없습니다.' : null,
)

useEffect(() => {
if (missingParams) return
const key = `${code}:${stateParam}`
if (consumedCodes.has(key)) return
consumedCodes.add(key)

void (async () => {
try {
const response = await completeGithubLogin(code!, stateParam!)
applyLogin(response)
const dest = consumeReturnTo() ?? '/'
navigate(dest, { replace: true })
} catch (err) {
if (isApiError(err)) {
setError(err.message)
} else {
setError('로그인 처리 중 오류가 발생했습니다.')
}
}
})()
}, [missingParams, code, stateParam, applyLogin, navigate])

return (
<div className="min-h-svh bg-bg text-fg flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md text-center">
{error ? (
<>
<p className="text-caption font-mono uppercase tracking-[0.22em] text-danger-700">
Login failed
</p>
<h1 className="mt-3 font-heading font-extrabold text-sage-900 text-h4">
로그인을 완료하지 못했어요
</h1>
<p className="mt-3 text-fg-strong/80 leading-relaxed">{error}</p>
<Link
to="/login"
replace
className="mt-8 inline-flex items-center justify-center gap-2 h-11 px-6 rounded-pill bg-sage-900 hover:bg-sage-800 text-white text-button transition-colors duration-fast"
>
로그인 페이지로 돌아가기
</Link>
</>
) : (
<>
<Spinner />
<p className="mt-6 text-caption font-mono uppercase tracking-[0.22em] text-sage-500">
Authenticating
</p>
<h1 className="mt-2 font-heading font-extrabold text-sage-900 text-h4">
GitHub 로그인 처리 중…
</h1>
<p className="mt-3 text-fg-strong/80">
잠시만 기다려주세요. 곧 이동합니다.
</p>
</>
)}
</div>
</div>
)
}

function Spinner() {
return (
<svg
aria-hidden
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
className="animate-spin mx-auto text-sage-500"
>
<circle cx="12" cy="12" r="9" strokeOpacity="0.2" />
<path d="M21 12a9 9 0 0 0-9-9" strokeLinecap="round" />
</svg>
)
}
1 change: 1 addition & 0 deletions frontend/src/pages/Login/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ui/LoginPage'
121 changes: 121 additions & 0 deletions frontend/src/pages/Login/ui/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { Link, Navigate, useLocation } from 'react-router-dom'
import { GithubLoginButton, useAuth } from '@/features/auth'
import { rememberReturnTo } from '@/features/auth/lib/return-to'
import { isApiError } from '@/shared/api'

type LocationState = { returnTo?: string } | null

export default function LoginPage() {
const [error, setError] = useState<string | null>(null)
const location = useLocation()
const { status } = useAuth()

const returnTo = (location.state as LocationState)?.returnTo ?? null

useEffect(() => {
rememberReturnTo(returnTo)
}, [returnTo])

if (status === 'authenticated') {
return <Navigate to={returnTo ?? '/'} replace />
}

return (
<div className="min-h-svh bg-bg text-fg flex flex-col">
<header className="sticky top-0 z-10 bg-bg/85 backdrop-blur-md border-b border-border/60">
<div className="mx-auto max-w-content px-6 lg:px-12 h-16 flex items-center justify-between">
<Link
to="/"
className="font-heading font-extrabold tracking-[0.04em] text-sage-900 text-[15px] uppercase"
>
Stack Up
</Link>
<Link
to="/"
className="text-button text-fg-strong/80 hover:text-fg-strong transition-colors duration-fast"
>
← 홈으로
</Link>
</div>
</header>

<main className="flex-1 flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md">
<div className="text-center">
<p className="text-caption font-mono uppercase tracking-[0.22em] text-sage-500">
Sign in
</p>
<h1
className="mt-4 font-heading font-extrabold text-sage-900 leading-[1.05]"
style={{ fontSize: 'clamp(36px, 5vw, 52px)', letterSpacing: '-0.5px' }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 tailwind 대신 인라인 스타일을 쓰셨군요
임시라면 상관없겠지만 하나로 통일하는게 좋아보입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 애매하긴 하네요. 사실 taliwind의 한계때문에 이렇게 써서 tailwind로 사용하려면 토큰화 시켜야하긴하는데 이부분은 좀 더 생각해보겠습니다.

>
모의 면접을<br />시작해볼까요?
</h1>
<p className="mt-5 text-fg-strong/80 leading-relaxed">
GitHub 계정으로 로그인하면<br />
당신의 레포지토리 기반 맞춤 면접이 준비됩니다.
</p>
</div>

<div className="mt-10 bg-surface-raised rounded-2xl border border-border shadow-md p-8">
<GithubLoginButton
className="w-full"
onError={(err) => {
if (isApiError(err)) {
setError(err.message)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선 백엔드에서 던진 메세지를 그대로 전달하는군요
백엔드에서 어떤 메세지를 던지는지도 확인해봐야겠네요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MVP구현단계에선 빠른 출시,개발이 중요해서 콘솔로그나 서비스가 터지지않는 에러처리를 하고 있지만, 여유가 생긴다면 logger를 도입해서 로깅시스템과 대시보드 등을 구축하면 좋을 것 같습니다.
백엔드는 당연히 해야하는게 맞고, 프론트도 하면 좋을 것 같아요.

} else {
setError('로그인을 시작할 수 없습니다. 잠시 후 다시 시도해주세요.')
}
}}
/>

{error ? (
<div
role="alert"
className="mt-4 rounded-md border border-danger-500/30 bg-danger-50 px-3 py-2 text-caption text-danger-700"
>
{error}
</div>
) : null}

<p className="mt-6 text-caption text-fg-muted text-center leading-relaxed">
계속 진행하면 StackUp의{' '}
<a href="#" className="underline hover:text-fg-strong">이용약관</a>과{' '}
<a href="#" className="underline hover:text-fg-strong">개인정보 처리방침</a>에
동의하는 것으로 간주됩니다.
</p>
</div>

<ul className="mt-10 grid grid-cols-3 gap-4 text-center">
{features.map((f) => (
<li key={f.title}>
<div className="text-h6 font-heading font-extrabold text-sage-700">
{f.icon}
</div>
<div className="mt-2 text-caption font-mono uppercase tracking-[0.18em] text-sage-500">
{f.title}
</div>
</li>
))}
</ul>
</div>
</main>

<footer className="border-t border-border">
<div className="mx-auto max-w-content px-6 lg:px-12 h-14 flex items-center justify-between text-caption font-mono text-fg-muted">
<span>© 2026 StackUp · CNU 종합설계</span>
<Link to="/" className="hover:text-fg-strong transition-colors duration-fast">
← Back to home
</Link>
</div>
</footer>
</div>
)
}

const features = [
{ icon: '01', title: 'Repo 분석' },
{ icon: '02', title: 'AI 면접' },
{ icon: '03', title: '피드백' },
]
1 change: 1 addition & 0 deletions frontend/src/pages/Workspace/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ui/WorkspacePage'
Loading