로그인에서부터 홈화면까지.
This commit is contained in:
parent
356710378f
commit
ea1f515f9a
26
node_modules/.package-lock.json
generated
vendored
26
node_modules/.package-lock.json
generated
vendored
@ -778,6 +778,32 @@
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/icons-material": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz",
|
||||
"integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mui/material": "^7.1.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz",
|
||||
|
||||
40
node_modules/.vite/deps/_metadata.json
generated
vendored
40
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,59 +1,77 @@
|
||||
{
|
||||
"hash": "71f20bb9",
|
||||
"hash": "808c9b20",
|
||||
"configHash": "b430cc57",
|
||||
"lockfileHash": "156318c4",
|
||||
"browserHash": "45073ce6",
|
||||
"lockfileHash": "cee5e061",
|
||||
"browserHash": "643b84c8",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "4b742355",
|
||||
"fileHash": "def11a46",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "3673b9dc",
|
||||
"fileHash": "cac9811e",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "de4ed0e4",
|
||||
"fileHash": "3ef2b459",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "7b97833d",
|
||||
"fileHash": "4941ddb6",
|
||||
"needsInterop": true
|
||||
},
|
||||
"@mui/icons-material/Visibility": {
|
||||
"src": "../../@mui/icons-material/esm/Visibility.js",
|
||||
"file": "@mui_icons-material_Visibility.js",
|
||||
"fileHash": "f492c9a6",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@mui/icons-material/VisibilityOff": {
|
||||
"src": "../../@mui/icons-material/esm/VisibilityOff.js",
|
||||
"file": "@mui_icons-material_VisibilityOff.js",
|
||||
"fileHash": "433e6d31",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@mui/material": {
|
||||
"src": "../../@mui/material/esm/index.js",
|
||||
"file": "@mui_material.js",
|
||||
"fileHash": "03cd2cda",
|
||||
"fileHash": "b45ab47f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "9b04da09",
|
||||
"fileHash": "edbadc29",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "5998c002",
|
||||
"fileHash": "8a958b52",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../react-router-dom/dist/index.mjs",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "6ec7518a",
|
||||
"fileHash": "a1ddff9e",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-C6WWHQR7": {
|
||||
"file": "chunk-C6WWHQR7.js"
|
||||
},
|
||||
"chunk-XSAQMLO6": {
|
||||
"file": "chunk-XSAQMLO6.js"
|
||||
},
|
||||
"chunk-JNNNAK6O": {
|
||||
"file": "chunk-JNNNAK6O.js"
|
||||
},
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
@ -1214,6 +1215,32 @@
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/icons-material": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz",
|
||||
"integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mui/material": "^7.1.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
// src/App.tsx
|
||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import LoginPage from './pages/Login'
|
||||
import RegisterCharacter from './pages/RegisterCharacter'
|
||||
import CharacterList from './pages/CharacterList'
|
||||
import CharacterHomeworks from './pages/CharacterHomeworks'
|
||||
import Signup from './pages/Signup'
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@ -15,17 +17,20 @@ const darkTheme = createTheme({
|
||||
})
|
||||
|
||||
function App() {
|
||||
const { isLoggedIn } = useAuth()
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<Layout>
|
||||
<Layout key={isLoggedIn ? 'in' : 'out'}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/characters/register" element={<RegisterCharacter />} />
|
||||
<Route path="/characters" element={<CharacterList />} />
|
||||
<Route path="/characters/:characterId/homeworks" element={<CharacterHomeworks />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
|
||||
@ -1,43 +1,87 @@
|
||||
import { AppBar, Toolbar, Typography, Button, Box } from '@mui/material'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const isLoggedIn = true // 임시: 로그인 여부에 따라 메뉴 분기
|
||||
import { AppBar, Toolbar, Typography, Button, Menu, MenuItem } from '@mui/material'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const { isLoggedIn, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [anchorElCharacter, setAnchorElCharacter] = useState<null | HTMLElement>(null)
|
||||
const [anchorElHomework, setAnchorElHomework] = useState<null | HTMLElement>(null)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const handleMenuOpen = (
|
||||
setter: React.Dispatch<React.SetStateAction<null | HTMLElement>>
|
||||
) => (event: React.MouseEvent<HTMLElement>) => {
|
||||
setter(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = (
|
||||
setter: React.Dispatch<React.SetStateAction<null | HTMLElement>>
|
||||
) => () => {
|
||||
setter(null)
|
||||
}
|
||||
|
||||
const menuItems = isLoggedIn ? (
|
||||
<>
|
||||
<Button component={Link} to="/dashboard" color="inherit">대시보드</Button>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={handleMenuOpen(setAnchorElCharacter)}
|
||||
>
|
||||
캐릭터
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorElCharacter}
|
||||
open={Boolean(anchorElCharacter)}
|
||||
onClose={handleMenuClose(setAnchorElCharacter)}
|
||||
>
|
||||
<MenuItem component={Link} to="/characters" onClick={handleMenuClose(setAnchorElCharacter)}>캐릭터 목록</MenuItem>
|
||||
<MenuItem component={Link} to="/characters/me/homeworks" onClick={handleMenuClose(setAnchorElCharacter)}>내 숙제</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={handleMenuOpen(setAnchorElHomework)}
|
||||
>
|
||||
숙제
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorElHomework}
|
||||
open={Boolean(anchorElHomework)}
|
||||
onClose={handleMenuClose(setAnchorElHomework)}
|
||||
>
|
||||
<MenuItem component={Link} to="/homeworks" onClick={handleMenuClose(setAnchorElHomework)}>숙제 목록</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button color="inherit" onClick={handleLogout}>로그아웃</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button component={Link} to="/login" color="inherit">로그인</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static" color="default">
|
||||
<AppBar position="static" key={location.pathname}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
<Button component={Link} to="/" color="inherit">
|
||||
숙제노기
|
||||
</Button>
|
||||
</Typography>
|
||||
|
||||
{!isLoggedIn ? (
|
||||
<Button component={Link} to="/login" color="inherit">
|
||||
로그인
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button component={Link} to="/" color="inherit">
|
||||
홈
|
||||
</Button>
|
||||
<Button component={Link} to="/characters" color="inherit">
|
||||
캐릭터 목록
|
||||
</Button>
|
||||
<Button component={Link} to="/characters/register" color="inherit">
|
||||
캐릭터 등록
|
||||
</Button>
|
||||
<Button component={Link} to="/logout" color="inherit">
|
||||
로그아웃
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{menuItems}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: 4 }}>{children}</Box>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
41
src/contexts/AuthContext.tsx
Normal file
41
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
interface AuthContextType {
|
||||
isLoggedIn: boolean
|
||||
token: string | null
|
||||
login: (token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('access_token')
|
||||
if (saved) setToken(saved)
|
||||
}, [])
|
||||
|
||||
const login = (newToken: string) => {
|
||||
localStorage.setItem('access_token', newToken)
|
||||
setToken(newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
setToken(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isLoggedIn: !!token, token, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider')
|
||||
return context
|
||||
}
|
||||
34
src/lib/api.ts
Normal file
34
src/lib/api.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://api.biryu2000.kr:8000',
|
||||
})
|
||||
|
||||
// 요청 시 토큰 자동 추가
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 응답 시 토큰 만료(401) 자동 처리
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 로그아웃 처리
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('token_type')
|
||||
// 리로드 또는 리다이렉트
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@ -3,8 +3,13 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// ✅ 추가: AuthProvider import
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</AuthProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@ -1,51 +1,45 @@
|
||||
// src/pages/CharacterList.tsx
|
||||
import { Box, Card, CardContent, Typography, Grid, Button } from '@mui/material'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from '../api/axios';
|
||||
|
||||
interface Character {
|
||||
id: number;
|
||||
name: string;
|
||||
server: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function CharacterListPage() {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCharacters = async () => {
|
||||
try {
|
||||
const res = await axios.get('/characters');
|
||||
setCharacters(res.data);
|
||||
} catch (err) {
|
||||
setError('캐릭터 목록을 불러오지 못했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchCharacters();
|
||||
}, []);
|
||||
const dummyCharacters = [
|
||||
{ id: 1, name: '한울이', level: 45, job: '궁수', server: '라사', power: 21000 },
|
||||
{ id: 2, name: '별이', level: 32, job: '사제', server: '라사', power: 18000 },
|
||||
]
|
||||
|
||||
export default function CharacterList() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white p-6">
|
||||
<h2 className="text-2xl font-semibold text-blue-600 mb-4">내 캐릭터 목록</h2>
|
||||
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||
{characters.length === 0 ? (
|
||||
<p>등록된 캐릭터가 없습니다.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{characters.map((char) => (
|
||||
<li key={char.id} className="border p-4 rounded shadow">
|
||||
<p><strong>이름:</strong> {char.name}</p>
|
||||
<p><strong>서버:</strong> {char.server}</p>
|
||||
<p><strong>등록일:</strong> {new Date(char.created_at).toLocaleString()}</p>
|
||||
</li>
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
캐릭터 목록
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{dummyCharacters.map((char) => (
|
||||
<Grid item key={char.id} xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{char.name}</Typography>
|
||||
<Typography>레벨: {char.level}</Typography>
|
||||
<Typography>직업: {char.job}</Typography>
|
||||
<Typography>서버: {char.server}</Typography>
|
||||
<Typography>전투력: {char.power}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card
|
||||
component={Link}
|
||||
to="/characters/register"
|
||||
sx={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', textDecoration: 'none' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" align="center">
|
||||
+ 캐릭터 추가
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default CharacterListPage;
|
||||
|
||||
@ -1,30 +1,41 @@
|
||||
import { Box, Card, CardContent, Typography, Button, Grid } from '@mui/material'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const dummyHomeworks = [
|
||||
{ id: 1, character: '한울이', title: '그림자 미션', status: '미완료' },
|
||||
{ id: 2, character: '별이', title: '히든던전 입장', status: '완료' },
|
||||
{ id: 3, character: '달이', title: '정령 성장 숙제', status: '미완료' },
|
||||
]
|
||||
// 가짜 숙제 데이터는 그대로 유지...
|
||||
const dummyHomeworks = [ /* ... */ ]
|
||||
|
||||
export default function Home() {
|
||||
const { isLoggedIn } = useAuth()
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>이번 주 내 숙제</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{dummyHomeworks.map(hw => (
|
||||
<Grid item xs={12} sm={6} md={4} key={hw.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">[{hw.character}] {hw.title}</Typography>
|
||||
<Typography color="text.secondary">상태: {hw.status}</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" fullWidth>상세보기</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box sx={{ p: 6, textAlign: 'center' }}>
|
||||
<Typography variant="h4" gutterBottom>숙제노기에 오신 걸 환영합니다 📝</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
이곳은 당신의 마비노기 숙제를 손쉽게 관리하는 웹 앱입니다.
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/login">
|
||||
로그인 하러 가기
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ 로그인 상태일 때 기존 숙제 카드 화면 출력
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>이번 주 내 숙제</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
<>
|
||||
{dummyHomeworks.map(hw => (
|
||||
<Box key={hw.id} sx={{ width: 300, p: 2, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 3 }}>
|
||||
<Typography variant="h6">[{hw.character}] {hw.title}</Typography>
|
||||
<Typography color="text.secondary">상태: {hw.status}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,34 @@
|
||||
import { Box, Button, Container, TextField, Typography, Paper } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext' // ✅ useAuth import
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogin = () => {
|
||||
console.log('로그인 시도:', { username, password })
|
||||
// TODO: 로그인 API 연동
|
||||
const { login } = useAuth() // ✅ 전역 login 함수 가져오기
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
const { access_token, token_type } = res.data
|
||||
console.log('로그인 성공:', access_token)
|
||||
|
||||
login(access_token) // ✅ 전역 상태 + localStorage 동시 반영
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setError('로그인 실패: 이메일 또는 비밀번호가 올바르지 않습니다.')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -17,25 +38,25 @@ export default function LoginPage() {
|
||||
로그인
|
||||
</Typography>
|
||||
|
||||
<Box component="form" noValidate autoComplete="off" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box component="form" noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="아이디"
|
||||
variant="outlined"
|
||||
label="이메일"
|
||||
type="email"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="비밀번호"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" fullWidth onClick={handleLogin}>
|
||||
<Button variant="contained" onClick={handleLogin}>
|
||||
로그인
|
||||
</Button>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
@ -1,56 +1,39 @@
|
||||
// src/pages/RegisterCharacter.tsx
|
||||
import { Box, Button, Container, Paper, TextField, Typography } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import axios from '../api/axios';
|
||||
export default function RegisterCharacter() {
|
||||
const [name, setName] = useState('')
|
||||
const [server, setServer] = useState('')
|
||||
|
||||
function RegisterCharacterPage() {
|
||||
const [server, setServer] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.post(
|
||||
'/characters',
|
||||
{ server, name },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setMessage(`등록 성공: ${res.data.name}`);
|
||||
} catch (err) {
|
||||
setMessage('등록 실패. 이름과 서버를 확인해주세요.');
|
||||
const handleSubmit = () => {
|
||||
console.log('캐릭터 등록:', { name, server })
|
||||
// 추후 API 연동 예정
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="w-full max-w-sm p-6 rounded-xl shadow-lg bg-gray-50">
|
||||
<h2 className="text-2xl font-semibold text-center text-blue-600 mb-6">캐릭터 등록</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={server}
|
||||
onChange={(e) => setServer(e.target.value)}
|
||||
placeholder="서버"
|
||||
className="w-full mb-3 px-4 py-2 border rounded-md"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<Container maxWidth="sm">
|
||||
<Paper sx={{ p: 4, mt: 8 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
캐릭터 등록
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="캐릭터명"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="캐릭터 이름"
|
||||
className="w-full mb-3 px-4 py-2 border rounded-md"
|
||||
fullWidth
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full bg-green-600 text-white py-2 rounded-md hover:bg-green-700"
|
||||
>
|
||||
등록하기
|
||||
</button>
|
||||
{message && <p className="text-center mt-4 text-sm text-gray-700">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<TextField
|
||||
label="서버"
|
||||
value={server}
|
||||
onChange={(e) => setServer(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
등록
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterCharacterPage;
|
||||
|
||||
|
||||
192
src/pages/Signup.tsx
Normal file
192
src/pages/Signup.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
InputAdornment,
|
||||
IconButton
|
||||
} from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../lib/api'
|
||||
import Visibility from '@mui/icons-material/Visibility'
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
|
||||
export default function Signup() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailStatus, setEmailStatus] = useState<'available' | 'duplicate' | ''>('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [strengthScore, setStrengthScore] = useState(0)
|
||||
const [strengthLabel, setStrengthLabel] = useState<'약함' | '보통' | '강함' | ''>('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const analyzePassword = (pw: string) => {
|
||||
let score = 0
|
||||
if (/[a-z]/.test(pw)) score++
|
||||
if (/[A-Z]/.test(pw)) score++
|
||||
if (/\d/.test(pw)) score++
|
||||
if (/[^A-Za-z0-9]/.test(pw)) score++
|
||||
if (pw.length >= 10) score++
|
||||
|
||||
setStrengthScore(score)
|
||||
|
||||
if (score <= 2) setStrengthLabel('약함')
|
||||
else if (score <= 4) setStrengthLabel('보통')
|
||||
else setStrengthLabel('강함')
|
||||
}
|
||||
|
||||
const validatePassword = (pw: string) => {
|
||||
const regex = /^(?=.*[A-Za-z])(?=.*\d).{6,20}$/
|
||||
return regex.test(pw)
|
||||
}
|
||||
|
||||
const validateEmailFormat = (value: string) => {
|
||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return regex.test(value)
|
||||
}
|
||||
|
||||
const checkEmailDuplicate = async (value: string) => {
|
||||
if (!validateEmailFormat(value)) {
|
||||
setEmailError('유효한 이메일 형식을 입력해주세요.')
|
||||
setEmailStatus('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.get(`http://api.biryu2000.kr:8000/auth/check-email?email=${value}`)
|
||||
if (res.data.available === false) {
|
||||
setEmailError('이미 존재하는 이메일입니다.')
|
||||
setEmailStatus('duplicate')
|
||||
} else {
|
||||
setEmailError('')
|
||||
setEmailStatus('available')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setEmailError('서버 오류로 이메일 확인 실패')
|
||||
setEmailStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignup = async () => {
|
||||
setError('')
|
||||
|
||||
if (!validateEmailFormat(email)) {
|
||||
setError('유효한 이메일 형식을 입력해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!validatePassword(password)) {
|
||||
setError('비밀번호는 영문자+숫자를 포함한 6~20자여야 합니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('http://api.biryu2000.kr:8000/users/', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
navigate('/login')
|
||||
} catch (err: any) {
|
||||
setError('회원가입 실패: 이미 존재하는 이메일이거나 서버 오류입니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs">
|
||||
<Paper elevation={3} sx={{ p: 4, mt: 8 }}>
|
||||
<Typography variant="h5" align="center" gutterBottom>
|
||||
회원가입
|
||||
</Typography>
|
||||
|
||||
<Box component="form" noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="이메일"
|
||||
type="email"
|
||||
fullWidth
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter') handleSignup()
|
||||
else checkEmailDuplicate(email)
|
||||
}}
|
||||
error={!!emailError}
|
||||
helperText={emailError || (emailStatus === 'available' ? '사용 가능한 이메일입니다.' : ' ')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="비밀번호"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
const pw = e.target.value
|
||||
setPassword(pw)
|
||||
analyzePassword(pw)
|
||||
}}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleSignup()}
|
||||
helperText="영문자+숫자 포함 6~20자, 특수문자 허용"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{strengthLabel && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
비밀번호 강도: {strengthLabel}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(strengthScore / 5) * 100}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#555',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor:
|
||||
strengthLabel === '강함' ? '#4caf50' :
|
||||
strengthLabel === '보통' ? '#ff9800' :
|
||||
'#f44336',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="비밀번호 확인"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleSignup()}
|
||||
/>
|
||||
|
||||
<Button variant="contained" onClick={handleSignup} disabled={!!emailError}>
|
||||
회원가입
|
||||
</Button>
|
||||
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user