diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a8cf54a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:8080
diff --git a/.gitignore b/.gitignore
index a547bf3..438657a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
+.env
# Editor directories and files
.vscode/*
diff --git a/package-lock.json b/package-lock.json
index 66a731c..c8b112a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1571,6 +1572,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/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2410,6 +2424,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
+ "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
+ "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.13.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
+ "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.13.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2471,6 +2523,12 @@
"semver": "bin/semver.js"
}
},
+ "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/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/package.json b/package.json
index 15a039f..32cc121 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/src/App.css b/src/App.css
index b9d355d..2acca77 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,42 +1 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
+/* Global app styles - kept minimal, page-level styles in pages/ */
diff --git a/src/App.jsx b/src/App.jsx
index f67355a..2a75c7e 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,35 +1,27 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-import './App.css'
-
-function App() {
- const [count, setCount] = useState(0)
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { AuthProvider, useAuth } from './context/AuthContext';
+import LoginPage from './pages/LoginPage';
+import HomePage from './pages/HomePage';
+function AppRoutes() {
+ const { user } = useAuth();
return (
- <>
-
- Vite + React
-
-
-
- Edit src/App.jsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- )
+
+ : }
+ />
+ } />
+
+ );
}
-export default App
+export default function App() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/api/announcements.js b/src/api/announcements.js
new file mode 100644
index 0000000..85ca033
--- /dev/null
+++ b/src/api/announcements.js
@@ -0,0 +1,39 @@
+import { apiFetch } from './client';
+
+// TODO: 백엔드 연동 시 mock 제거
+const USE_MOCK = true;
+
+const MOCK_DATA = [
+ {
+ id: 1,
+ title: '오픈 베타 테스트 안내',
+ content: '2월 28일부터 오픈 베타 테스트가 시작됩니다. 많은 참여 부탁드립니다.',
+ createdAt: '2026-02-24',
+ },
+ {
+ id: 2,
+ title: '클라이언트 v0.2.0 업데이트',
+ content: '멀티플레이어 매칭 시스템이 개선되었습니다. 런처를 통해 업데이트해주세요.',
+ createdAt: '2026-02-20',
+ },
+ {
+ id: 3,
+ title: '서버 점검 안내 (2/18)',
+ content: '2월 18일 02:00 ~ 06:00 서버 점검이 진행됩니다.',
+ createdAt: '2026-02-17',
+ },
+ {
+ id: 4,
+ title: '테스터 모집 공고',
+ content: '신규 테스터를 모집합니다. 관심 있으신 분은 신청해주세요.',
+ createdAt: '2026-02-10',
+ },
+];
+
+export async function getAnnouncements() {
+ if (USE_MOCK) {
+ await new Promise((r) => setTimeout(r, 300));
+ return MOCK_DATA;
+ }
+ return apiFetch('/api/announcements');
+}
diff --git a/src/api/auth.js b/src/api/auth.js
new file mode 100644
index 0000000..0276a96
--- /dev/null
+++ b/src/api/auth.js
@@ -0,0 +1,16 @@
+import { apiFetch } from './client';
+
+// TODO: 백엔드 연동 시 mock 제거
+const USE_MOCK = true;
+
+export async function login(username, password) {
+ if (USE_MOCK) {
+ await new Promise((r) => setTimeout(r, 500));
+ if (!username || !password) throw new Error('아이디와 비밀번호를 입력해주세요.');
+ return { token: 'mock-jwt-token', username };
+ }
+ return apiFetch('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ });
+}
diff --git a/src/api/client.js b/src/api/client.js
new file mode 100644
index 0000000..72680a8
--- /dev/null
+++ b/src/api/client.js
@@ -0,0 +1,15 @@
+const BASE = import.meta.env.VITE_API_BASE_URL || '';
+
+export async function apiFetch(path, options = {}) {
+ const token = localStorage.getItem('token');
+ const headers = { 'Content-Type': 'application/json', ...options.headers };
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+
+ const res = await fetch(BASE + path, { ...options, headers });
+ if (!res.ok) {
+ const err = new Error(res.statusText);
+ err.status = res.status;
+ throw err;
+ }
+ return res.json();
+}
diff --git a/src/api/download.js b/src/api/download.js
new file mode 100644
index 0000000..6fff175
--- /dev/null
+++ b/src/api/download.js
@@ -0,0 +1,17 @@
+import { apiFetch } from './client';
+
+// TODO: 백엔드 연동 시 mock 제거
+const USE_MOCK = true;
+
+export async function getDownloadInfo() {
+ if (USE_MOCK) {
+ await new Promise((r) => setTimeout(r, 200));
+ return {
+ url: '#',
+ version: 'v0.2.0',
+ fileSize: '1.2 GB',
+ fileName: 'A301_Launcher_Setup.exe',
+ };
+ }
+ return apiFetch('/api/download/info');
+}
diff --git a/src/components/AnnouncementBoard.css b/src/components/AnnouncementBoard.css
new file mode 100644
index 0000000..30eb071
--- /dev/null
+++ b/src/components/AnnouncementBoard.css
@@ -0,0 +1,61 @@
+.announcement-board {
+ margin-top: 32px;
+}
+
+.announcement-heading {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0 0 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(186, 205, 176, 0.15);
+}
+
+.announcement-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.announcement-item {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.announcement-row {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 8px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ transition: background 0.15s;
+ text-align: left;
+}
+
+.announcement-row:hover {
+ background: rgba(186, 205, 176, 0.06);
+}
+
+.announcement-title {
+ font-size: 0.95rem;
+ color: rgba(255, 255, 255, 0.85);
+ font-weight: 500;
+}
+
+.announcement-date {
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.35);
+ flex-shrink: 0;
+ margin-left: 16px;
+}
+
+.announcement-content {
+ padding: 12px 8px 20px;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.6);
+ line-height: 1.6;
+}
diff --git a/src/components/AnnouncementBoard.jsx b/src/components/AnnouncementBoard.jsx
new file mode 100644
index 0000000..c56e1ab
--- /dev/null
+++ b/src/components/AnnouncementBoard.jsx
@@ -0,0 +1,34 @@
+import { useState, useEffect } from 'react';
+import { getAnnouncements } from '../api/announcements';
+import './AnnouncementBoard.css';
+
+export default function AnnouncementBoard() {
+ const [list, setList] = useState([]);
+ const [expanded, setExpanded] = useState(null);
+
+ useEffect(() => {
+ getAnnouncements().then(setList);
+ }, []);
+
+ return (
+
+ 공지사항
+
+ {list.map((item) => (
+ -
+
+ {expanded === item.id && (
+
{item.content}
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/DownloadSection.css b/src/components/DownloadSection.css
new file mode 100644
index 0000000..428fef6
--- /dev/null
+++ b/src/components/DownloadSection.css
@@ -0,0 +1,40 @@
+.download-section {
+ background: rgba(186, 205, 176, 0.06);
+ border: 1px solid rgba(186, 205, 176, 0.12);
+ border-radius: 12px;
+ padding: 48px 40px;
+ text-align: center;
+}
+
+.download-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #BACDB0;
+ margin: 0 0 12px;
+}
+
+.download-meta {
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.45);
+ margin: 0 0 28px;
+}
+
+.btn-download {
+ display: inline-block;
+ padding: 16px 48px;
+ background: #BACDB0;
+ color: #2E2C2F;
+ border: none;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 700;
+ text-decoration: none;
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.15s;
+ letter-spacing: 0.05em;
+}
+
+.btn-download:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+}
diff --git a/src/components/DownloadSection.jsx b/src/components/DownloadSection.jsx
new file mode 100644
index 0000000..daab2a0
--- /dev/null
+++ b/src/components/DownloadSection.jsx
@@ -0,0 +1,38 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import { getDownloadInfo } from '../api/download';
+import './DownloadSection.css';
+
+export default function DownloadSection() {
+ const [info, setInfo] = useState(null);
+ const { user } = useAuth();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ getDownloadInfo().then(setInfo);
+ }, []);
+
+ const handleDownload = (e) => {
+ if (!user) {
+ e.preventDefault();
+ navigate('/login');
+ }
+ };
+
+ if (!info) return null;
+
+ return (
+
+ );
+}
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..98076ad
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -0,0 +1,37 @@
+import { createContext, useContext, useState, useCallback } from 'react';
+import { login as apiLogin } from '../api/auth';
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(() => {
+ const token = localStorage.getItem('token');
+ const username = localStorage.getItem('username');
+ return token ? { token, username } : null;
+ });
+
+ const login = useCallback(async (username, password) => {
+ const data = await apiLogin(username, password);
+ localStorage.setItem('token', data.token);
+ localStorage.setItem('username', data.username);
+ setUser({ token: data.token, username: data.username });
+ }, []);
+
+ const logout = useCallback(() => {
+ localStorage.removeItem('token');
+ localStorage.removeItem('username');
+ setUser(null);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider');
+ return ctx;
+}
diff --git a/src/index.css b/src/index.css
index 08a3ac9..371c3c3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,68 +1,33 @@
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ font-family: 'Pretendard', system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
-
- color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
+ background-color: #2E2C2F;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
-}
-
body {
- margin: 0;
- display: flex;
- place-items: center;
min-width: 320px;
min-height: 100vh;
}
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
+a {
+ color: #BACDB0;
+ text-decoration: none;
}
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
+a:hover {
+ opacity: 0.85;
}
diff --git a/src/pages/HomePage.css b/src/pages/HomePage.css
new file mode 100644
index 0000000..1daf66a
--- /dev/null
+++ b/src/pages/HomePage.css
@@ -0,0 +1,111 @@
+.home-page {
+ min-height: 100vh;
+ background-color: #2E2C2F;
+}
+
+/* Header */
+.home-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 32px;
+ border-bottom: 1px solid rgba(186, 205, 176, 0.1);
+}
+
+.home-logo {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: #BACDB0;
+ letter-spacing: 0.1em;
+ margin: 0;
+}
+
+.home-user {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.home-username {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.btn-logout {
+ padding: 8px 16px;
+ background: transparent;
+ color: rgba(186, 205, 176, 0.7);
+ border: 1px solid rgba(186, 205, 176, 0.25);
+ border-radius: 6px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.btn-logout:hover {
+ background: rgba(186, 205, 176, 0.08);
+ border-color: rgba(186, 205, 176, 0.45);
+}
+
+.btn-header-login {
+ padding: 8px 20px;
+ background: #BACDB0;
+ color: #2E2C2F;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition: opacity 0.2s;
+}
+
+.btn-header-login:hover {
+ opacity: 0.9;
+}
+
+/* Hero banner */
+.hero-banner {
+ position: relative;
+ height: 280px;
+ background:
+ linear-gradient(135deg, rgba(46, 44, 47, 0.85), rgba(46, 44, 47, 0.6)),
+ linear-gradient(135deg, #2E2C2F 0%, #3a3a3a 50%, #2E2C2F 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.hero-banner::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(ellipse at 30% 40%, rgba(186, 205, 176, 0.12) 0%, transparent 60%),
+ radial-gradient(ellipse at 70% 60%, rgba(186, 205, 176, 0.06) 0%, transparent 50%);
+}
+
+.hero-overlay {
+ position: relative;
+ text-align: center;
+}
+
+.hero-title {
+ font-size: 2.8rem;
+ font-weight: 800;
+ color: #BACDB0;
+ letter-spacing: 0.12em;
+ margin: 0;
+}
+
+.hero-desc {
+ font-size: 1rem;
+ color: rgba(255, 255, 255, 0.5);
+ margin: 12px 0 0;
+}
+
+/* Main content */
+.home-main {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 40px 24px 80px;
+}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
new file mode 100644
index 0000000..be73af8
--- /dev/null
+++ b/src/pages/HomePage.jsx
@@ -0,0 +1,39 @@
+import { Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import DownloadSection from '../components/DownloadSection';
+import AnnouncementBoard from '../components/AnnouncementBoard';
+import './HomePage.css';
+
+export default function HomePage() {
+ const { user, logout } = useAuth();
+
+ return (
+
+
+
+
+
+
A301 MULTIPLAYER
+
Unity 3D 멀티플레이어 테스트에 참여하세요
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/LoginPage.css b/src/pages/LoginPage.css
new file mode 100644
index 0000000..fb6abd8
--- /dev/null
+++ b/src/pages/LoginPage.css
@@ -0,0 +1,159 @@
+.login-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #2E2C2F;
+ background-image:
+ radial-gradient(ellipse at 20% 50%, rgba(186, 205, 176, 0.08) 0%, transparent 50%),
+ radial-gradient(ellipse at 80% 50%, rgba(186, 205, 176, 0.05) 0%, transparent 50%);
+}
+
+.login-panel {
+ width: 100%;
+ max-width: 400px;
+ padding: 48px 40px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(186, 205, 176, 0.15);
+ border-radius: 12px;
+ backdrop-filter: blur(10px);
+}
+
+.login-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+.game-title {
+ font-size: 3rem;
+ font-weight: 800;
+ color: #BACDB0;
+ letter-spacing: 0.15em;
+ margin: 0;
+ line-height: 1;
+}
+
+.game-subtitle {
+ font-size: 0.85rem;
+ color: rgba(186, 205, 176, 0.6);
+ letter-spacing: 0.5em;
+ margin: 8px 0 0;
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.input-group label {
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.6);
+ font-weight: 500;
+}
+
+.input-group input {
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(186, 205, 176, 0.2);
+ border-radius: 8px;
+ color: #fff;
+ font-size: 0.95rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.input-group input::placeholder {
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.input-group input:focus {
+ border-color: #BACDB0;
+}
+
+.login-error {
+ color: #e57373;
+ font-size: 0.85rem;
+ margin: 0;
+ text-align: center;
+}
+
+.btn-login {
+ padding: 14px;
+ background: #BACDB0;
+ color: #2E2C2F;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ margin-top: 4px;
+}
+
+.btn-login:hover {
+ opacity: 0.9;
+}
+
+.btn-login:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.login-divider {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin: 24px 0;
+}
+
+.login-divider::before,
+.login-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.login-divider span {
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.btn-ssafy {
+ width: 100%;
+ padding: 12px;
+ background: transparent;
+ color: rgba(186, 205, 176, 0.8);
+ border: 1px solid rgba(186, 205, 176, 0.3);
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.btn-ssafy:hover {
+ background: rgba(186, 205, 176, 0.08);
+ border-color: rgba(186, 205, 176, 0.5);
+}
+
+.login-back {
+ display: block;
+ text-align: center;
+ margin-top: 24px;
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.4);
+ text-decoration: none;
+ transition: color 0.2s;
+}
+
+.login-back:hover {
+ color: rgba(255, 255, 255, 0.7);
+}
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..d24952d
--- /dev/null
+++ b/src/pages/LoginPage.jsx
@@ -0,0 +1,84 @@
+import { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import './LoginPage.css';
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { login } = useAuth();
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ await login(username, password);
+ navigate('/', { replace: true });
+ } catch (err) {
+ setError(err.message || '로그인에 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ 또는
+
+
+
+
+
메인으로 돌아가기
+
+
+ );
+}