diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..a397ee5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,12 @@ - + - - frontend + + + + 스터디카페 랭킹
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eec3ac7..31e723c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", @@ -3202,6 +3203,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -5229,6 +5243,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5523,6 +5575,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a64f0c8..2312138 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index a66b5ef..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' - -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) -} - -export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..5173bfc --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,40 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +const HomeIcon = ( + +); +const MyIcon = ( + +); +const RankIcon = ( + +); +const SchoolIcon = ( + +); + +const tabs: { to: string; label: string; icon: ReactNode; end?: boolean }[] = [ + { to: '/', label: '홈', icon: HomeIcon, end: true }, + { to: '/my', label: '마이', icon: MyIcon }, + { to: '/ranking', label: '랭킹', icon: RankIcon }, + { to: '/school', label: '우리학교', icon: SchoolIcon }, +]; + +export default function AppLayout() { + return ( +
+
+ +
+ +
+ ); +} diff --git a/frontend/src/components/Placeholder.tsx b/frontend/src/components/Placeholder.tsx new file mode 100644 index 0000000..8a0672b --- /dev/null +++ b/frontend/src/components/Placeholder.tsx @@ -0,0 +1,15 @@ +export default function Placeholder({ title, message }: { title: string; message: string }) { + return ( + <> +
+

{title}

+
+
+
+ {message} +
곧 만나요
+
+
+ + ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..f705fd4 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,32 @@ +import { type ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; + +export default function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, ready, loadError, retry } = useAuth(); + + if (!ready) { + return ( +
+
+
+ ); + } + + // 네트워크/서버 오류 — 토큰이 아직 유효할 수 있으니 로그인으로 내보내지 않고 재시도를 준다. + if (loadError) { + return ( +
+
+ 연결에 문제가 있어 정보를 불러오지 못했어요. +
+ +
+
+
+ ); + } + + if (!user) return ; + return <>{children}; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb3313..f472c63 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,111 +1,146 @@ +/* ===== 스터디카페 랭킹 — 디자인 시스템 (챌린지 네이비 + 골드) ===== */ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; + --ground:#f1f2f4; --surface:#ffffff; --surface-2:#f3f4f7; + --ink:#182338; --ink-2:#3e4a60; --muted:#6f7788; --faint:#a0a7b4; + --line:#e3e5ea; --line-2:#eef0f3; + --primary:#1c4fa2; --primary-ink:#153c7d; --primary-soft:#e6eefa; + --on-primary:#ffffff; /* --primary 배경 위 전경색 (대비 8:1) */ + --gold:#e0a219; --gold-ink:#95680d; --gold-soft:#f8ebc7; + --active:#1f9d63; --danger:#c8402f; + --shadow:0 1px 2px rgba(16,28,54,.05), 0 8px 24px -12px rgba(16,28,54,.2); + --radius:20px; --radius-sm:14px; color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } - @media (prefers-color-scheme: dark) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); + --ground:#0d1422; --surface:#151d2e; --surface-2:#1c2740; + --ink:#e9edf4; --ink-2:#bfc8d6; --muted:#8b98ac; --faint:#586377; + --line:#25324a; --line-2:#1e2940; + --primary:#5a90e6; --primary-ink:#83abef; --primary-soft:#1b2b49; + --on-primary:#0d1422; /* 밝은 파랑 위엔 흰색(3.2:1)이 부족 → 어두운 전경색(5.7:1) */ + --gold:#efbd4b; --gold-ink:#f3c968; --gold-soft:#2e2611; + --active:#37b87c; --danger:#f0705f; + --shadow:0 1px 2px rgba(0,0,0,.34), 0 14px 32px -14px rgba(0,0,0,.6); } } -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } body { - margin: 0; + font-family: -apple-system, "Apple SD Gothic Neo", "Pretendard", system-ui, "Segoe UI", Roboto, sans-serif; + background: var(--ground); + color: var(--ink); + -webkit-font-smoothing: antialiased; + line-height: 1.5; } +.num { font-variant-numeric: tabular-nums lining-nums; font-feature-settings: "tnum" 1, "lnum" 1; } +a { color: inherit; text-decoration: none; } +button { font: inherit; cursor: pointer; } +:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +/* ===== 모바일 앱 셸 ===== */ +#root { display: flex; justify-content: center; } +.shell { + width: 100%; max-width: 460px; height: 100dvh; overflow: hidden; + background: var(--surface); + display: flex; flex-direction: column; + box-shadow: 0 0 0 1px var(--line); +} +.page { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.app-body { flex: 1; overflow-y: auto; min-height: 0; padding: 4px 20px 24px; } +.topbar { + flex: 0 0 auto; background: var(--surface); + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px 12px; } +.topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: -.02em; } +.topbar .hi { font-size: 12.5px; color: var(--muted); } +.topbar .sub { font-size: 12px; color: var(--faint); font-weight: 600; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } +/* ===== 하단 탭 ===== */ +.bottom-nav { + flex: 0 0 auto; + display: flex; border-top: 1px solid var(--line); background: var(--surface); + padding: 9px 6px calc(12px + env(safe-area-inset-bottom, 0px)); } -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } +.bottom-nav a { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; + font-size: 10.5px; font-weight: 600; color: var(--faint); } -p { - margin: 0; +.bottom-nav a svg { width: 22px; height: 22px; } +.bottom-nav a.active { color: var(--primary-ink); } + +/* ===== 카드 / 타일 ===== */ +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; } +.card + .card { margin-top: 12px; } +.card.sub { background: var(--surface-2); border-color: transparent; } +.lbl { font-size: 10.5px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); } + +/* ===== 버튼 ===== */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 14px 18px; border-radius: var(--radius-sm); font-size: 15.5px; font-weight: 670; + border: none; background: var(--primary); color: var(--on-primary); transition: filter .12s, opacity .12s; } +.btn svg { width: 18px; height: 18px; } +.btn.full { width: 100%; } +.btn:hover { filter: brightness(1.06); } +.btn:active { filter: brightness(.94); } +.btn:disabled { opacity: .55; cursor: default; filter: none; } +.btn.ghost { background: var(--surface-2); color: var(--ink-2); } +.btn.gold { background: var(--gold); color: #3a2a06; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +/* ===== 폼 ===== */ +.field { display: flex; flex-direction: column; gap: 7px; } +.field + .field { margin-top: 16px; } +.field label { font-size: 12.5px; font-weight: 640; color: var(--ink-2); } +.input, .select { + width: 100%; padding: 13px 14px; border-radius: 12px; font-size: 15px; + border: 1.5px solid var(--line); background: var(--surface); color: var(--ink); + transition: border-color .12s; } +.input::placeholder { color: var(--faint); } +.input:focus, .select:focus { outline: none; border-color: var(--primary); } +.input.err, .select.err { border-color: var(--danger); } +.hint-txt { font-size: 11.5px; color: var(--muted); } +.err-txt { font-size: 12px; color: var(--danger); font-weight: 600; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* ===== Auth 화면 ===== */ +.auth { display: flex; flex-direction: column; min-height: 100dvh; padding: 40px 26px 32px; justify-content: center; } +.auth .brand { display: flex; align-items: center; gap: 11px; margin-bottom: 8px; } +.auth .brand .logo { + width: 40px; height: 40px; border-radius: 12px; background: var(--primary); color: var(--on-primary); + display: grid; place-items: center; +} +.auth .brand .logo svg { width: 22px; height: 22px; } +.auth .brand b { font-size: 17px; font-weight: 660; letter-spacing: -.01em; } +.auth h2 { font-size: 26px; font-weight: 730; letter-spacing: -.025em; margin: 20px 0 6px; } +.auth .lead { color: var(--muted); font-size: 14px; margin-bottom: 26px; } +.auth form { display: flex; flex-direction: column; } +.auth .actions { margin-top: 24px; display: flex; flex-direction: column; gap: 14px; } +.auth .switch { text-align: center; font-size: 13.5px; color: var(--muted); } +.auth .switch a { color: var(--primary-ink); font-weight: 650; } +.banner { + display: flex; gap: 8px; align-items: flex-start; font-size: 13px; font-weight: 600; + padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); margin-bottom: 18px; } +.banner.info { background: var(--primary-soft); color: var(--primary-ink); } +.link-btn { + background: none; border: none; padding: 0; font-size: inherit; font-weight: 700; + color: var(--primary-ink); text-decoration: underline; +} + +/* ===== 상태/공용 ===== */ +.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 5px 11px; border-radius: 999px; } +.pill.studying { background: var(--gold-soft); color: var(--gold-ink); } +.pill.idle { background: var(--surface-2); color: var(--muted); } +.dot { width: 7px; height: 7px; border-radius: 50%; background: currentcolor; } +.dot.live { animation: pulse 2.4s ease-in-out infinite; } +@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } } +.center-msg { text-align: center; color: var(--muted); padding: 48px 20px; font-size: 14px; } +.spinner { width: 22px; height: 22px; border: 2.5px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.loading-screen { min-height: 100dvh; display: grid; place-items: center; } +.soon { color: var(--faint); font-size: 13px; margin-top: 6px; } +@media (prefers-reduced-motion: reduce) { .dot.live, .spinner { animation: none; } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..d50ab06 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,108 @@ +// ===== 백엔드 API 클라이언트 + 타입 ===== + +export interface School { + id: number; + name: string; + shortName: string | null; +} + +export interface User { + id: number; + loginId: string; + displayName: string; + nameSeq: number; + schoolId: number | null; + schoolName: string | null; +} + +export interface LoginResponse { + token: string; + tokenType: string; + user: User; +} + +export interface CurrentSession { + active: boolean; + sessionId: number | null; + checkInAt: string | null; + cafeName: string | null; +} + +export interface SignupInput { + loginId: string; + password: string; + displayName: string; + schoolId: number | null; +} + +export interface ApiErrorBody { + status?: number; + error?: string; + message?: string; + fieldErrors?: Record; +} + +const TOKEN_KEY = 'scr.token'; +export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); +export const setToken = (token: string | null): void => { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +}; + +type UnauthorizedHandler = () => void; +let unauthorizedHandler: UnauthorizedHandler | null = null; + +/** 인증 만료(401/403)를 앱 전역에서 한 번에 처리하기 위한 훅. AuthProvider 가 등록한다. */ +export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null): void => { + unauthorizedHandler = handler; +}; + +export class ApiError extends Error { + status: number; + body: ApiErrorBody | null; + constructor(status: number, body: ApiErrorBody | null) { + super(body?.message ?? `요청에 실패했어요 (${status})`); + this.status = status; + this.body = body; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const res = await fetch('/api' + path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + // 토큰을 실어 보낸 요청이 401/403이면 세션 만료로 간주하고 인증 상태를 정리한다. + // (로그인 실패의 401은 토큰 없이 보낸 요청이라 여기 해당하지 않는다.) + // 응답이 늦게 오는 사이 다른 토큰으로 로그인했을 수 있으므로, 보낸 토큰이 아직 그대로일 때만 정리한다. + // 그러지 않으면 만료 토큰의 뒤늦은 401이 방금 발급받은 세션을 지운다. + if (token && (res.status === 401 || res.status === 403) && getToken() === token) { + setToken(null); + unauthorizedHandler?.(); + } + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + if (!res.ok) throw new ApiError(res.status, body); + return body as T; +} + +export const authApi = { + login: (loginId: string, password: string) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ loginId, password }), + }), + signup: (input: SignupInput) => + request('/auth/signup', { method: 'POST', body: JSON.stringify(input) }), + me: () => request('/users/me'), + schools: () => request('/schools'), +}; + +export const sessionApi = { + current: () => request('/sessions/current'), +}; diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..cb56a03 --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,120 @@ +import { + createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode, +} from 'react'; +import { + ApiError, authApi, getToken, setToken, setUnauthorizedHandler, + type SignupInput, type User, +} from './api'; +import { AutoLoginFailedError } from './authErrors'; + +interface AuthContextValue { + user: User | null; + ready: boolean; // 초기 토큰 검증 완료 여부 + loadError: boolean; // 네트워크/서버 오류로 검증 실패 (토큰 유지, 재시도 가능) + login: (loginId: string, password: string) => Promise; + signup: (input: SignupInput) => Promise; + logout: () => void; + retry: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(false); + + // 진행 중인 부트스트랩의 세대 번호. 로그인/로그아웃/만료로 인증 상태가 확정되면 + // 번호를 올려, 늦게 도착한 검증 결과가 확정된 상태를 덮어쓰지 못하게 한다. + const bootstrapSeq = useRef(0); + const invalidateBootstrap = useCallback(() => { + bootstrapSeq.current += 1; + }, []); + + const bootstrap = useCallback(async () => { + const seq = bootstrapSeq.current + 1; + bootstrapSeq.current = seq; + const stale = () => bootstrapSeq.current !== seq; + + if (!getToken()) { + setUser(null); + setLoadError(false); + setReady(true); + return; + } + setReady(false); + setLoadError(false); + try { + const me = await authApi.me(); + if (stale()) return; + setUser(me); + } catch (err) { + if (stale()) return; + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + setUser(null); // 만료/무효 토큰 정리는 api 계층이 이미 했다 → 로그인 화면으로 + } else { + setLoadError(true); // 네트워크/5xx 는 토큰 유지하고 재시도 가능하게 + } + } finally { + if (!stale()) setReady(true); + } + }, []); + + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + + // 인증 만료를 한 곳에서: api 계층이 401/403에서 토큰을 지우고, 여기서 사용자 상태를 비운다. + useEffect(() => { + setUnauthorizedHandler(() => { + invalidateBootstrap(); + setUser(null); + // 만료는 '확정된' 상태다. 이전 검증이 남긴 연결 오류를 지우지 않으면 + // 토큰이 정리됐는데도 화면이 로그인 대신 '연결 문제'에 머문다. + setLoadError(false); + setReady(true); + }); + return () => setUnauthorizedHandler(null); + }, [invalidateBootstrap]); + + const login = async (loginId: string, password: string) => { + const res = await authApi.login(loginId, password); + invalidateBootstrap(); // 진행 중이던 이전 토큰 검증이 새 세션을 덮어쓰지 않도록 + setToken(res.token); + setUser(res.user); + setLoadError(false); + setReady(true); + }; + + const signup = async (input: SignupInput) => { + await authApi.signup(input); // 여기서 실패하면 '가입 실패'로 그대로 전파 + try { + await login(input.loginId, input.password); + } catch { + throw new AutoLoginFailedError(); // 계정은 만들어졌으므로 재가입을 유도하면 안 된다 + } + }; + + const logout = () => { + invalidateBootstrap(); + setToken(null); + setUser(null); + setLoadError(false); + setReady(true); + }; + + return ( + void bootstrap() }} + > + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth는 AuthProvider 안에서만 사용할 수 있어요.'); + return ctx; +} diff --git a/frontend/src/lib/authErrors.ts b/frontend/src/lib/authErrors.ts new file mode 100644 index 0000000..32321e2 --- /dev/null +++ b/frontend/src/lib/authErrors.ts @@ -0,0 +1,7 @@ +/** 가입은 성공했는데 자동 로그인만 실패한 경우 — 재가입이 아니라 로그인으로 안내해야 한다. */ +export class AutoLoginFailedError extends Error { + constructor() { + super('가입은 완료됐어요. 아래 로그인에서 로그인해 주세요.'); + this.name = 'AutoLoginFailedError'; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..5f5a013 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,40 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; +import './index.css'; +import { AuthProvider } from './lib/auth'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppLayout from './components/AppLayout'; +import LoginPage from './pages/LoginPage'; +import SignupPage from './pages/SignupPage'; +import HomePage from './pages/HomePage'; +import MyPage from './pages/MyPage'; +import RankingPage from './pages/RankingPage'; +import SchoolPage from './pages/SchoolPage'; + +const router = createBrowserRouter([ + { path: '/login', element: }, + { path: '/signup', element: }, + { + element: ( + + + + ), + children: [ + { path: '/', element: }, + { path: '/my', element: }, + { path: '/ranking', element: }, + { path: '/school', element: }, + ], + }, + { path: '*', element: }, +]); createRoot(document.getElementById('root')!).render( - + + + , -) +); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..956427c --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAuth } from '../lib/auth'; +import { sessionApi, type CurrentSession } from '../lib/api'; + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); +} + +const cardStyle = { display: 'flex', flexDirection: 'column', gap: 12 } as const; + +export default function HomePage() { + const { user, logout } = useAuth(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setFailed(false); + try { + setSession(await sessionApi.current()); + } catch { + // 실패를 '기록 없음'으로 숨기지 않는다 — 구분해서 재시도를 준다. + setFailed(true); + setSession(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( + <> +
+
+
안녕하세요,
+

{user?.displayName}님

+
+ +
+ +
+ {loading ? ( +
불러오는 중…
+ ) : failed ? ( +
+ 불러오지 못함 +
공부 상태를 불러오지 못했어요.
+ +
+ ) : session?.active ? ( +
+ 공부 중 +
+ {session.cafeName}에서 공부하고 있어요 + {session.checkInAt && <> · {fmtTime(session.checkInAt)} 시작} +
+

QR 체크아웃 화면은 다음 업데이트에서 추가돼요.

+
+ ) : ( +
+ 대기 중 +
지금은 진행 중인 공부 기록이 없어요.
+

QR로 체크인하는 화면은 다음 업데이트에서 추가돼요.

+
+ )} +
+ + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..6c25c74 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,62 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { ApiError } from '../lib/api'; + +const Logo = ( + +); + +export default function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + await login(loginId.trim(), password); + navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : '로그인에 실패했어요. 다시 시도해 주세요.'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {Logo} + 스터디카페 랭킹 +
+

다시 오셨네요

+

아이디로 로그인하고 오늘도 기록을 쌓아요.

+ +
+ {error &&
{error}
} +
+ + setLoginId(e.target.value)} placeholder="아이디" /> +
+
+ + setPassword(e.target.value)} placeholder="비밀번호" /> +
+
+ +

아직 계정이 없나요? 회원가입

+
+
+
+ ); +} diff --git a/frontend/src/pages/MyPage.tsx b/frontend/src/pages/MyPage.tsx new file mode 100644 index 0000000..627820a --- /dev/null +++ b/frontend/src/pages/MyPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function MyPage() { + return ; +} diff --git a/frontend/src/pages/RankingPage.tsx b/frontend/src/pages/RankingPage.tsx new file mode 100644 index 0000000..f20414d --- /dev/null +++ b/frontend/src/pages/RankingPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function RankingPage() { + return ; +} diff --git a/frontend/src/pages/SchoolPage.tsx b/frontend/src/pages/SchoolPage.tsx new file mode 100644 index 0000000..c9ac0e3 --- /dev/null +++ b/frontend/src/pages/SchoolPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function SchoolPage() { + return ; +} diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx new file mode 100644 index 0000000..a5ad86f --- /dev/null +++ b/frontend/src/pages/SignupPage.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { AutoLoginFailedError } from '../lib/authErrors'; +import { ApiError, authApi, type School } from '../lib/api'; + +const Logo = ( + +); + +export default function SignupPage() { + const { signup } = useAuth(); + const navigate = useNavigate(); + const [schools, setSchools] = useState([]); + const [schoolsFailed, setSchoolsFailed] = useState(false); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [schoolId, setSchoolId] = useState(''); // '' = 무소속 + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); // 가입 성공 + 자동 로그인만 실패 + const [fieldErrors, setFieldErrors] = useState>({}); + const [busy, setBusy] = useState(false); + + const loadSchools = useCallback(async () => { + setSchoolsFailed(false); + try { + setSchools(await authApi.schools()); + } catch { + setSchoolsFailed(true); // 조용히 삼키지 않고 알린다 (무소속 가입은 계속 가능) + } + }, []); + + useEffect(() => { + void loadSchools(); + }, [loadSchools]); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setNotice(''); + setFieldErrors({}); + setBusy(true); + try { + await signup({ + loginId: loginId.trim(), + password, + displayName: displayName.trim(), + schoolId: schoolId ? Number(schoolId) : null, + }); + navigate('/', { replace: true }); + } catch (err) { + if (err instanceof AutoLoginFailedError) { + setNotice(err.message); // 계정은 만들어졌으니 재가입이 아니라 로그인으로 안내 + } else if (err instanceof ApiError) { + setFieldErrors(err.body?.fieldErrors ?? {}); + setError(err.body?.fieldErrors ? '입력값을 확인해 주세요.' : err.message); + } else { + setError('회원가입에 실패했어요. 다시 시도해 주세요.'); + } + } finally { + setBusy(false); + } + }; + + const done = Boolean(notice); // 가입 완료됨 → 다시 제출하면 중복 오류 + + return ( +
+
+ {Logo} + 스터디카페 랭킹 +
+

시작하기

+

몇 가지만 입력하면 바로 기록을 시작할 수 있어요.

+ +
+ {error &&
{error}
} + {notice &&
{notice}
} + +
+ + setLoginId(e.target.value)} placeholder="3~30자" /> + {fieldErrors.loginId && {fieldErrors.loginId}} +
+ +
+ + setPassword(e.target.value)} placeholder="8자 이상" /> + {fieldErrors.password && {fieldErrors.password}} +
+ +
+ + setDisplayName(e.target.value)} placeholder="예: 김민현" /> + {fieldErrors.displayName + ? {fieldErrors.displayName} + : 랭킹에는 김O현처럼 가운데를 가려 표시돼요.} +
+ +
+ + + {schoolsFailed ? ( + + 학교 목록을 불러오지 못했어요. 무소속으로는 가입할 수 있어요.{' '} + + + ) : ( + 목록에 없으면 무소속을 선택하세요. 학교 랭킹에만 영향을 줘요. + )} +
+ +
+ +

+ {done ? '가입이 끝났어요. ' : '이미 계정이 있나요? '} + 로그인 +

+
+
+
+ ); +}