로그인에서부터 홈화면까지.

This commit is contained in:
SR07 2025-05-14 18:03:56 +09:00
parent 356710378f
commit ea1f515f9a
14 changed files with 572 additions and 170 deletions

26
node_modules/.package-lock.json generated vendored
View File

@ -778,6 +778,32 @@
"url": "https://opencollective.com/mui-org" "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": { "node_modules/@mui/material": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz",

View File

@ -1,59 +1,77 @@
{ {
"hash": "71f20bb9", "hash": "808c9b20",
"configHash": "b430cc57", "configHash": "b430cc57",
"lockfileHash": "156318c4", "lockfileHash": "cee5e061",
"browserHash": "45073ce6", "browserHash": "643b84c8",
"optimized": { "optimized": {
"react": { "react": {
"src": "../../react/index.js", "src": "../../react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "4b742355", "fileHash": "def11a46",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../react-dom/index.js", "src": "../../react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "3673b9dc", "fileHash": "cac9811e",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js", "src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "de4ed0e4", "fileHash": "3ef2b459",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../react/jsx-runtime.js", "src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "7b97833d", "fileHash": "4941ddb6",
"needsInterop": true "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": { "@mui/material": {
"src": "../../@mui/material/esm/index.js", "src": "../../@mui/material/esm/index.js",
"file": "@mui_material.js", "file": "@mui_material.js",
"fileHash": "03cd2cda", "fileHash": "b45ab47f",
"needsInterop": false "needsInterop": false
}, },
"axios": { "axios": {
"src": "../../axios/index.js", "src": "../../axios/index.js",
"file": "axios.js", "file": "axios.js",
"fileHash": "9b04da09", "fileHash": "edbadc29",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../react-dom/client.js", "src": "../../react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "5998c002", "fileHash": "8a958b52",
"needsInterop": true "needsInterop": true
}, },
"react-router-dom": { "react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs", "src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js", "file": "react-router-dom.js",
"fileHash": "6ec7518a", "fileHash": "a1ddff9e",
"needsInterop": false "needsInterop": false
} }
}, },
"chunks": { "chunks": {
"chunk-C6WWHQR7": {
"file": "chunk-C6WWHQR7.js"
},
"chunk-XSAQMLO6": {
"file": "chunk-XSAQMLO6.js"
},
"chunk-JNNNAK6O": { "chunk-JNNNAK6O": {
"file": "chunk-JNNNAK6O.js" "file": "chunk-JNNNAK6O.js"
}, },

27
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"react": "^19.0.0", "react": "^19.0.0",
@ -1214,6 +1215,32 @@
"url": "https://opencollective.com/mui-org" "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": { "node_modules/@mui/material": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@ -1,12 +1,14 @@
// src/App.tsx // src/App.tsx
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material' import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import Layout from './components/Layout' import Layout from './components/Layout'
import Home from './pages/Home' import Home from './pages/Home'
import LoginPage from './pages/Login' import LoginPage from './pages/Login'
import RegisterCharacter from './pages/RegisterCharacter' import RegisterCharacter from './pages/RegisterCharacter'
import CharacterList from './pages/CharacterList' import CharacterList from './pages/CharacterList'
import CharacterHomeworks from './pages/CharacterHomeworks' import CharacterHomeworks from './pages/CharacterHomeworks'
import Signup from './pages/Signup'
const darkTheme = createTheme({ const darkTheme = createTheme({
palette: { palette: {
@ -15,17 +17,20 @@ const darkTheme = createTheme({
}) })
function App() { function App() {
const { isLoggedIn } = useAuth()
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
<Router> <Router>
<Layout> <Layout key={isLoggedIn ? 'in' : 'out'}>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/characters/register" element={<RegisterCharacter />} /> <Route path="/characters/register" element={<RegisterCharacter />} />
<Route path="/characters" element={<CharacterList />} /> <Route path="/characters" element={<CharacterList />} />
<Route path="/characters/:characterId/homeworks" element={<CharacterHomeworks />} /> <Route path="/characters/:characterId/homeworks" element={<CharacterHomeworks />} />
<Route path="/signup" element={<Signup />} />
</Routes> </Routes>
</Layout> </Layout>
</Router> </Router>

View File

@ -1,43 +1,87 @@
import { AppBar, Toolbar, Typography, Button, Box } from '@mui/material' import { AppBar, Toolbar, Typography, Button, Menu, MenuItem } from '@mui/material'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
const isLoggedIn = true // 임시: 로그인 여부에 따라 메뉴 분기 import { useAuth } from '../contexts/AuthContext'
import { useState } from 'react'
export default function Layout({ children }: { children: React.ReactNode }) { 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 ( return (
<> <>
<AppBar position="static" color="default"> <AppBar position="static" key={location.pathname}>
<Toolbar> <Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}> <Typography variant="h6" sx={{ flexGrow: 1 }}>
<Button component={Link} to="/" color="inherit"> <Button component={Link} to="/" color="inherit">
</Button> </Button>
</Typography> </Typography>
{menuItems}
{!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>
</>
)}
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Box sx={{ p: 4 }}>{children}</Box> {children}
</> </>
) )
} }

View 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
View 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

View File

@ -3,8 +3,13 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
// ✅ 추가: AuthProvider import
import { AuthProvider } from './contexts/AuthContext'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <AuthProvider>
</StrictMode>, <App />
</AuthProvider>
</StrictMode>
) )

View File

@ -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'; const dummyCharacters = [
import axios from '../api/axios'; { id: 1, name: '한울이', level: 45, job: '궁수', server: '라사', power: 21000 },
{ id: 2, name: '별이', level: 32, job: '사제', server: '라사', power: 18000 },
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();
}, []);
export default function CharacterList() {
return ( return (
<div className="min-h-screen bg-white p-6"> <Box sx={{ p: 4 }}>
<h2 className="text-2xl font-semibold text-blue-600 mb-4"> </h2> <Typography variant="h5" gutterBottom>
{error && <p className="text-red-500 mb-4">{error}</p>}
{characters.length === 0 ? ( </Typography>
<p> .</p> <Grid container spacing={2}>
) : ( {dummyCharacters.map((char) => (
<ul className="space-y-2"> <Grid item key={char.id} xs={12} sm={6} md={4}>
{characters.map((char) => ( <Card>
<li key={char.id} className="border p-4 rounded shadow"> <CardContent>
<p><strong>:</strong> {char.name}</p> <Typography variant="h6">{char.name}</Typography>
<p><strong>:</strong> {char.server}</p> <Typography>: {char.level}</Typography>
<p><strong>:</strong> {new Date(char.created_at).toLocaleString()}</p> <Typography>: {char.job}</Typography>
</li> <Typography>: {char.server}</Typography>
<Typography>: {char.power}</Typography>
</CardContent>
</Card>
</Grid>
))} ))}
</ul> <Grid item xs={12} sm={6} md={4}>
)} <Card
</div> 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;

View File

@ -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: '미완료' }, const dummyHomeworks = [ /* ... */ ]
{ id: 2, character: '별이', title: '히든던전 입장', status: '완료' },
{ id: 3, character: '달이', title: '정령 성장 숙제', status: '미완료' },
]
export default function Home() { export default function Home() {
const { isLoggedIn } = useAuth()
if (!isLoggedIn) {
return (
<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 ( return (
<Box sx={{ p: 4 }}> <Box sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom> </Typography> <Typography variant="h5" gutterBottom> </Typography>
<Grid container spacing={3}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
<>
{dummyHomeworks.map(hw => ( {dummyHomeworks.map(hw => (
<Grid item xs={12} sm={6} md={4} key={hw.id}> <Box key={hw.id} sx={{ width: 300, p: 2, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 3 }}>
<Card> <Typography variant="h6">[{hw.character}] {hw.title}</Typography>
<CardContent> <Typography color="text.secondary">: {hw.status}</Typography>
<Typography variant="h6">[{hw.character}] {hw.title}</Typography> </Box>
<Typography color="text.secondary">: {hw.status}</Typography>
<Box sx={{ mt: 2 }}>
<Button variant="contained" fullWidth></Button>
</Box>
</CardContent>
</Card>
</Grid>
))} ))}
</Grid> </>
</Box>
</Box> </Box>
) )
} }

View File

@ -1,13 +1,34 @@
import { Box, Button, Container, TextField, Typography, Paper } from '@mui/material' import { Box, Button, Container, TextField, Typography, Paper } from '@mui/material'
import { useState } from 'react' 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() { export default function Login() {
const [username, setUsername] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
const handleLogin = () => { const { login } = useAuth() // ✅ 전역 login 함수 가져오기
console.log('로그인 시도:', { username, password })
// TODO: 로그인 API 연동 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 ( return (
@ -17,25 +38,25 @@ export default function LoginPage() {
</Typography> </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 <TextField
label="아이디" label="이메일"
variant="outlined" type="email"
fullWidth fullWidth
value={username} value={email}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
<TextField <TextField
label="비밀번호" label="비밀번호"
type="password" type="password"
variant="outlined"
fullWidth fullWidth
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<Button variant="contained" fullWidth onClick={handleLogin}> <Button variant="contained" onClick={handleLogin}>
</Button> </Button>
{error && <Typography color="error">{error}</Typography>}
</Box> </Box>
</Paper> </Paper>
</Container> </Container>

View File

@ -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'; export default function RegisterCharacter() {
import axios from '../api/axios'; const [name, setName] = useState('')
const [server, setServer] = useState('')
function RegisterCharacterPage() { const handleSubmit = () => {
const [server, setServer] = useState(''); console.log('캐릭터 등록:', { name, server })
const [name, setName] = useState(''); // 추후 API 연동 예정
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('등록 실패. 이름과 서버를 확인해주세요.');
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <Container maxWidth="sm">
<div className="w-full max-w-sm p-6 rounded-xl shadow-lg bg-gray-50"> <Paper sx={{ p: 4, mt: 8 }}>
<h2 className="text-2xl font-semibold text-center text-blue-600 mb-6"> </h2> <Typography variant="h5" gutterBottom>
<input
type="text" </Typography>
value={server} <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
onChange={(e) => setServer(e.target.value)} <TextField
placeholder="서버" label="캐릭터명"
className="w-full mb-3 px-4 py-2 border rounded-md" value={name}
/> onChange={(e) => setName(e.target.value)}
<input fullWidth
type="text" />
value={name} <TextField
onChange={(e) => setName(e.target.value)} label="서버"
placeholder="캐릭터 이름" value={server}
className="w-full mb-3 px-4 py-2 border rounded-md" onChange={(e) => setServer(e.target.value)}
/> fullWidth
<button />
onClick={handleSubmit} <Button variant="contained" onClick={handleSubmit}>
className="w-full bg-green-600 text-white py-2 rounded-md hover:bg-green-700"
> </Button>
</Box>
</button> </Paper>
{message && <p className="text-center mt-4 text-sm text-gray-700">{message}</p>} </Container>
</div> )
</div>
);
} }
export default RegisterCharacterPage;

192
src/pages/Signup.tsx Normal file
View 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>
)
}