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

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"
}
},
"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",

View File

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

@ -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",

View File

@ -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",

View File

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

View File

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

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 App from './App.tsx'
// ✅ 추가: AuthProvider import
import { AuthProvider } from './contexts/AuthContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
<StrictMode>
<AuthProvider>
<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';
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;

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: '미완료' },
{ 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: 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>
<Grid container spacing={3}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
<>
{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>
<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>
))}
</Grid>
</>
</Box>
</Box>
)
}

View File

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

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';
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"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="캐릭터 이름"
className="w-full mb-3 px-4 py-2 border rounded-md"
/>
<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>
);
<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)}
fullWidth
/>
<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
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>
)
}