Add frontend pages with login, download, and announcements
All checks were successful
Client CI/CD / deploy (push) Successful in 12s
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:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
src/App.css
43
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/ */
|
||||
|
||||
54
src/App.jsx
54
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 (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={user ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
39
src/api/announcements.js
Normal file
39
src/api/announcements.js
Normal 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
16
src/api/auth.js
Normal 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
15
src/api/client.js
Normal 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
17
src/api/download.js
Normal 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');
|
||||
}
|
||||
61
src/components/AnnouncementBoard.css
Normal file
61
src/components/AnnouncementBoard.css
Normal 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;
|
||||
}
|
||||
34
src/components/AnnouncementBoard.jsx
Normal file
34
src/components/AnnouncementBoard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/DownloadSection.css
Normal file
40
src/components/DownloadSection.css
Normal 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);
|
||||
}
|
||||
38
src/components/DownloadSection.jsx
Normal file
38
src/components/DownloadSection.jsx
Normal 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} · {info.fileSize} · {info.version}
|
||||
</p>
|
||||
<a href={info.url} download onClick={handleDownload} className="btn-download">
|
||||
{user ? '다운로드' : '로그인 후 다운로드'}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
src/context/AuthContext.jsx
Normal file
37
src/context/AuthContext.jsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
111
src/pages/HomePage.css
Normal file
111
src/pages/HomePage.css
Normal 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
39
src/pages/HomePage.jsx
Normal 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
159
src/pages/LoginPage.css
Normal 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
84
src/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user