diff --git a/frontend/index.html b/frontend/index.html index e8231331..aebe5dbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,16 +1,22 @@ - + + - StackUp + + StackUp — IT Interview Solution
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index cd9f4def..00000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { lazy, Suspense } from 'react' -import HomePage from '@/pages/Home' - -const DesignSystemDemo = lazy(() => import('@/pages/DesignSystem')) - -export default function App() { - const path = - typeof window !== 'undefined' ? window.location.pathname : '/' - - if (path.startsWith('/design-system')) { - return ( - - - - ) - } - - return -} diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx new file mode 100644 index 00000000..6714fbf8 --- /dev/null +++ b/frontend/src/app/providers/AppProviders.tsx @@ -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 ( + + + + + + + + ) +} diff --git a/frontend/src/app/providers/index.ts b/frontend/src/app/providers/index.ts index e69de29b..a7f6a1ad 100644 --- a/frontend/src/app/providers/index.ts +++ b/frontend/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProviders } from './AppProviders' diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx new file mode 100644 index 00000000..ee278444 --- /dev/null +++ b/frontend/src/app/router/index.tsx @@ -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: }, + { path: '/login', element: }, + { path: '/auth/callback', element: }, + { + path: '/workspace', + element: ( + + + + ), + }, + { + path: '/design-system/*', + lazy: async () => { + const mod = await import('@/pages/DesignSystem') + return { Component: mod.default } + }, + }, +]) diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index 50456a9f..e9302a3f 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -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' diff --git a/frontend/src/features/auth/model/useLogout.ts b/frontend/src/features/auth/model/useLogout.ts new file mode 100644 index 00000000..098cae0e --- /dev/null +++ b/frontend/src/features/auth/model/useLogout.ts @@ -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 } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 037249fa..93987cda 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + , ) diff --git a/frontend/src/pages/AuthCallback/index.ts b/frontend/src/pages/AuthCallback/index.ts new file mode 100644 index 00000000..9a9cdb1d --- /dev/null +++ b/frontend/src/pages/AuthCallback/index.ts @@ -0,0 +1 @@ +export { default } from './ui/AuthCallbackPage' diff --git a/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx b/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx new file mode 100644 index 00000000..ea8acaf0 --- /dev/null +++ b/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx @@ -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() + +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( + 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 ( +
+
+ {error ? ( + <> +

+ Login failed +

+

+ 로그인을 완료하지 못했어요 +

+

{error}

+ + 로그인 페이지로 돌아가기 + + + ) : ( + <> + +

+ Authenticating +

+

+ GitHub 로그인 처리 중… +

+

+ 잠시만 기다려주세요. 곧 이동합니다. +

+ + )} +
+
+ ) +} + +function Spinner() { + return ( + + + + + ) +} diff --git a/frontend/src/pages/Login/index.ts b/frontend/src/pages/Login/index.ts index e69de29b..befc7bf0 100644 --- a/frontend/src/pages/Login/index.ts +++ b/frontend/src/pages/Login/index.ts @@ -0,0 +1 @@ +export { default } from './ui/LoginPage' diff --git a/frontend/src/pages/Login/ui/LoginPage.tsx b/frontend/src/pages/Login/ui/LoginPage.tsx new file mode 100644 index 00000000..dd1e43d5 --- /dev/null +++ b/frontend/src/pages/Login/ui/LoginPage.tsx @@ -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(null) + const location = useLocation() + const { status } = useAuth() + + const returnTo = (location.state as LocationState)?.returnTo ?? null + + useEffect(() => { + rememberReturnTo(returnTo) + }, [returnTo]) + + if (status === 'authenticated') { + return + } + + return ( +
+
+
+ + Stack Up + + + ← 홈으로 + +
+
+ +
+
+
+

+ Sign in +

+

+ 모의 면접을
시작해볼까요? +

+

+ GitHub 계정으로 로그인하면
+ 당신의 레포지토리 기반 맞춤 면접이 준비됩니다. +

+
+ +
+ { + if (isApiError(err)) { + setError(err.message) + } else { + setError('로그인을 시작할 수 없습니다. 잠시 후 다시 시도해주세요.') + } + }} + /> + + {error ? ( +
+ {error} +
+ ) : null} + +

+ 계속 진행하면 StackUp의{' '} + 이용약관과{' '} + 개인정보 처리방침에 + 동의하는 것으로 간주됩니다. +

+
+ +
    + {features.map((f) => ( +
  • +
    + {f.icon} +
    +
    + {f.title} +
    +
  • + ))} +
+
+
+ +
+
+ © 2026 StackUp · CNU 종합설계 + + ← Back to home + +
+
+
+ ) +} + +const features = [ + { icon: '01', title: 'Repo 분석' }, + { icon: '02', title: 'AI 면접' }, + { icon: '03', title: '피드백' }, +] diff --git a/frontend/src/pages/Workspace/index.ts b/frontend/src/pages/Workspace/index.ts index e69de29b..2f98a28d 100644 --- a/frontend/src/pages/Workspace/index.ts +++ b/frontend/src/pages/Workspace/index.ts @@ -0,0 +1 @@ +export { default } from './ui/WorkspacePage' diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx new file mode 100644 index 00000000..adb7dd89 --- /dev/null +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -0,0 +1,51 @@ +import { useAuth, useLogout } from '@/features/auth' + +export default function WorkspacePage() { + const { user } = useAuth() + const { logout, loggingOut } = useLogout() + + return ( +
+

Workspace

+

이력서·레포 관리 화면이 들어갈 자리입니다.

+ + {user ? ( +
+ {user.avatarUrl ? ( + {user.githubUsername} + ) : null} +
+
+ @{user.githubUsername} +
+
+ {user.email ?? 'email 없음'} +
+
+
+ ) : null} + +
+ +
+
+ ) +} diff --git a/frontend/src/widgets/site-nav/ui/SiteNav.tsx b/frontend/src/widgets/site-nav/ui/SiteNav.tsx index fcc1a775..4e7a357b 100644 --- a/frontend/src/widgets/site-nav/ui/SiteNav.tsx +++ b/frontend/src/widgets/site-nav/ui/SiteNav.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { useAuth, useLogout } from '@/features/auth' const items = [ { href: '#services', label: 'Services' }, @@ -8,6 +10,8 @@ const items = [ export function SiteNav() { const [scrolled, setScrolled] = useState(false) + const { status, user } = useAuth() + const { logout, loggingOut } = useLogout() useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8) @@ -46,19 +50,55 @@ export function SiteNav() { ))} -
- - Get Started - - → - - +
+ {status === 'authenticated' ? ( + <> + + {user?.avatarUrl ? ( + + ) : null} + {user?.githubUsername ?? 'Workspace'} + + + + ) : ( + <> + + Login + + + Get Started + + → + + + + )}