Add frontend pages with login, download, and announcements
All checks were successful
Client CI/CD / deploy (push) Successful in 12s

- React Router v7: public home page, /login page
- Auth context with JWT localStorage management
- Login: ID/PW form + SSAFY login button (UI only)
- Home: hero banner, download section (login required), announcement board
- API layer with mock data (ready for Go Fiber backend)
- Color scheme: #2E2C2F dark + #BACDB0 accent
- Add .env.example for environment variable reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:40:29 +09:00
parent 1a472df39c
commit 7e2f9419ab
20 changed files with 792 additions and 125 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

60
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1571,6 +1572,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2410,6 +2424,44 @@
"node": ">=0.10.0" "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": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2471,6 +2523,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -1,42 +1 @@
#root { /* Global app styles - kept minimal, page-level styles in pages/ */
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;
}

View File

@@ -1,35 +1,27 @@
import { useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import reactLogo from './assets/react.svg' import { AuthProvider, useAuth } from './context/AuthContext';
import viteLogo from '/vite.svg' import LoginPage from './pages/LoginPage';
import './App.css' import HomePage from './pages/HomePage';
function App() {
const [count, setCount] = useState(0)
function AppRoutes() {
const { user } = useAuth();
return ( return (
<> <Routes>
<div> <Route
<a href="https://vite.dev" target="_blank"> path="/login"
<img src={viteLogo} className="logo" alt="Vite logo" /> element={user ? <Navigate to="/" replace /> : <LoginPage />}
</a> />
<a href="https://react.dev" target="_blank"> <Route path="/" element={<HomePage />} />
<img src={reactLogo} className="logo react" alt="React logo" /> </Routes>
</a> );
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
} }
export default App export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}

39
src/api/announcements.js Normal file
View File

@@ -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');
}

16
src/api/auth.js Normal file
View File

@@ -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 }),
});
}

15
src/api/client.js Normal file
View File

@@ -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();
}

17
src/api/download.js Normal file
View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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 (
<section className="announcement-board">
<h2 className="announcement-heading">공지사항</h2>
<ul className="announcement-list">
{list.map((item) => (
<li key={item.id} className="announcement-item">
<button
className="announcement-row"
onClick={() => setExpanded(expanded === item.id ? null : item.id)}
>
<span className="announcement-title">{item.title}</span>
<span className="announcement-date">{item.createdAt}</span>
</button>
{expanded === item.id && (
<div className="announcement-content">{item.content}</div>
)}
</li>
))}
</ul>
</section>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<section className="download-section">
<div className="download-content">
<h2 className="download-title">게임 런처 다운로드</h2>
<p className="download-meta">
{info.fileName} &middot; {info.fileSize} &middot; {info.version}
</p>
<a href={info.url} download onClick={handleDownload} className="btn-download">
{user ? '다운로드' : '로그인 후 다운로드'}
</a>
</div>
</section>
);
}

View File

@@ -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 (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -1,68 +1,33 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: 'Pretendard', system-ui, -apple-system, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: #242424; background-color: #2E2C2F;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body { body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }
h1 { a {
font-size: 3.2em; color: #BACDB0;
line-height: 1.1; text-decoration: none;
} }
button { a:hover {
border-radius: 8px; opacity: 0.85;
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;
}
} }

111
src/pages/HomePage.css Normal file
View File

@@ -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;
}

39
src/pages/HomePage.jsx Normal file
View File

@@ -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 (
<div className="home-page">
<header className="home-header">
<h1 className="home-logo">A301</h1>
<div className="home-user">
{user ? (
<>
<span className="home-username">{user.username}</span>
<button className="btn-logout" onClick={logout}>로그아웃</button>
</>
) : (
<Link to="/login" className="btn-header-login">로그인</Link>
)}
</div>
</header>
<section className="hero-banner">
<div className="hero-overlay">
<h2 className="hero-title">A301 MULTIPLAYER</h2>
<p className="hero-desc">Unity 3D 멀티플레이어 테스트에 참여하세요</p>
</div>
</section>
<main className="home-main">
<DownloadSection />
<AnnouncementBoard />
</main>
</div>
);
}

159
src/pages/LoginPage.css Normal file
View File

@@ -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);
}

84
src/pages/LoginPage.jsx Normal file
View File

@@ -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 (
<div className="login-page">
<div className="login-panel">
<div className="login-header">
<h1 className="game-title">A301</h1>
<p className="game-subtitle">MULTIPLAYER</p>
</div>
<form className="login-form" onSubmit={handleSubmit}>
<div className="input-group">
<label htmlFor="username">아이디</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="아이디를 입력하세요"
autoComplete="username"
/>
</div>
<div className="input-group">
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>
</div>
{error && <p className="login-error">{error}</p>}
<button type="submit" className="btn-login" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
<div className="login-divider">
<span>또는</span>
</div>
<button
type="button"
className="btn-ssafy"
onClick={() => alert('SSAFY 로그인은 준비 중입니다.')}
>
SSAFY 계정으로 로그인
</button>
<Link to="/" className="login-back">메인으로 돌아가기</Link>
</div>
</div>
);
}