v.1.0
This commit is contained in:
parent
c18f705286
commit
61ddda7fe6
@ -10,8 +10,11 @@ import RegisterCharacter from './pages/RegisterCharacter'
|
||||
import RegisterHomework from './pages/RegisterHomework'
|
||||
import CharacterList from './pages/CharacterList'
|
||||
import HomeworkList from './pages/HomeworkList'
|
||||
import CharacterHomeworkSelect from './pages/CharacterHomeworkSelect' // 정확한 경로로 수정
|
||||
import HomeworkEditPage from './pages/HomeworkEditPage'
|
||||
import CharacterHomeworkSelect from './pages/CharacterHomeworkSelect'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import MePage from './pages/MePage'
|
||||
import CharacterEditPage from './pages/CharacterEditPage'
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@ -38,6 +41,9 @@ function App() {
|
||||
<Route path="/characters/:characterId/homeworks" element={<CharacterHomeworkSelect />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/characters/:id/edit" element={<CharacterEditPage />} />
|
||||
<Route path="/homeworks/:id/edit" element={<HomeworkEditPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import { AppBar, Toolbar, Typography, Button, Menu, MenuItem } from '@mui/material'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
} from '@mui/material'
|
||||
import { AccountCircle } from '@mui/icons-material'
|
||||
import { Link, useNavigate, 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 { isLoggedIn, logout } = useAuth()
|
||||
|
||||
const [anchorElCharacter, setAnchorElCharacter] = useState<null | HTMLElement>(null)
|
||||
const [anchorElHomework, setAnchorElHomework] = useState<null | HTMLElement>(null)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
|
||||
const handleMenuOpen = (
|
||||
setter: React.Dispatch<React.SetStateAction<null | HTMLElement>>
|
||||
@ -29,16 +33,24 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
setter(null)
|
||||
}
|
||||
|
||||
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElUser(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleUserMenuClose = () => {
|
||||
setAnchorElUser(null)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const menuItems = isLoggedIn ? (
|
||||
<>
|
||||
<Button component={Link} to="/dashboard" color="inherit">대시보드</Button>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={handleMenuOpen(setAnchorElCharacter)}
|
||||
>
|
||||
캐릭터
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleMenuOpen(setAnchorElCharacter)}>캐릭터</Button>
|
||||
<Menu
|
||||
anchorEl={anchorElCharacter}
|
||||
open={Boolean(anchorElCharacter)}
|
||||
@ -48,12 +60,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<MenuItem component={Link} to="/characters/me/homeworks" onClick={handleMenuClose(setAnchorElCharacter)}>내 숙제</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={handleMenuOpen(setAnchorElHomework)}
|
||||
>
|
||||
숙제
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleMenuOpen(setAnchorElHomework)}>숙제</Button>
|
||||
<Menu
|
||||
anchorEl={anchorElHomework}
|
||||
open={Boolean(anchorElHomework)}
|
||||
@ -62,7 +69,21 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<MenuItem component={Link} to="/homeworks" onClick={handleMenuClose(setAnchorElHomework)}>숙제 목록</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button color="inherit" onClick={handleLogout}>로그아웃</Button>
|
||||
<IconButton color="inherit" onClick={handleUserMenuOpen} size="large">
|
||||
<AccountCircle />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorElUser}
|
||||
open={Boolean(anchorElUser)}
|
||||
onClose={handleUserMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => { handleUserMenuClose(); navigate('/me') }}>
|
||||
내 정보
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleUserMenuClose(); handleLogout() }}>
|
||||
로그아웃
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'https://api.biryu2000.kr',
|
||||
baseURL: 'http://localhost:8000',
|
||||
})
|
||||
|
||||
// 요청 시 토큰 자동 추가
|
||||
|
||||
110
src/pages/CharacterEditPage.tsx
Normal file
110
src/pages/CharacterEditPage.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
TextField,
|
||||
Typography,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../lib/api'
|
||||
|
||||
export default function CharacterEditPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [server, setServer] = useState('')
|
||||
const [combatPower, setCombatPower] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [openConfirm, setOpenConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/characters/${id}`)
|
||||
.then(res => {
|
||||
setName(res.data.name)
|
||||
setServer(res.data.server || '')
|
||||
setCombatPower(String(res.data.combat_power || ''))
|
||||
})
|
||||
.catch(() => setError('캐릭터 정보를 불러오는 데 실패했습니다.'))
|
||||
}, [id])
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.put(`/characters/${id}`, {
|
||||
name,
|
||||
server,
|
||||
power: Number(combatPower)
|
||||
})
|
||||
navigate('/characters')
|
||||
} catch {
|
||||
setError('캐릭터 수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/characters/${id}`)
|
||||
navigate('/characters')
|
||||
} catch {
|
||||
setError('캐릭터 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>캐릭터 수정</Typography>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<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
|
||||
/>
|
||||
<TextField
|
||||
label="전투력"
|
||||
value={combatPower}
|
||||
onChange={(e) => setCombatPower(e.target.value)}
|
||||
fullWidth
|
||||
type="number"
|
||||
/>
|
||||
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||
<Button variant="contained" onClick={handleUpdate}>수정</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => setOpenConfirm(true)}>삭제</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Dialog
|
||||
open={openConfirm}
|
||||
onClose={() => setOpenConfirm(false)}
|
||||
>
|
||||
<DialogTitle>삭제 확인</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>정말 이 캐릭터를 삭제하시겠습니까?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenConfirm(false)}>취소</Button>
|
||||
<Button onClick={handleDelete} color="error">삭제</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,14 @@ export default function CharacterList() {
|
||||
<Grid container spacing={2}>
|
||||
{characters.map((char) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={char.id} {...({} as any)}>
|
||||
<Card>
|
||||
<Card
|
||||
component={Link}
|
||||
to={`/characters/${char.id}/edit`}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{char.name}</Typography>
|
||||
<Typography color="text.secondary">서버: {char.server || '-'}</Typography>
|
||||
|
||||
114
src/pages/HomeworkEditPage.tsx
Normal file
114
src/pages/HomeworkEditPage.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import {
|
||||
Box, Button, Container, Paper, TextField, Typography, MenuItem,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../lib/api'
|
||||
|
||||
export default function HomeworkEditPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [resetType, setResetType] = useState('')
|
||||
const [clearCount, setClearCount] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [openConfirm, setOpenConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/homeworks/${id}`)
|
||||
.then(res => {
|
||||
const hw = res.data
|
||||
setTitle(hw.title)
|
||||
setDescription(hw.description || '')
|
||||
setResetType(hw.reset_type)
|
||||
setClearCount(String(hw.clear_count || ''))
|
||||
})
|
||||
.catch(() => setError('숙제 정보를 불러오는 데 실패했습니다.'))
|
||||
}, [id])
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.put(`/homeworks/${id}`, {
|
||||
name: title, // ✅ title → name
|
||||
description,
|
||||
repeat_type: resetType, // ✅ reset_type → repeat_type
|
||||
repeat_count: Number(clearCount) // ✅ clear_count → repeat_count
|
||||
})
|
||||
navigate('/homeworks')
|
||||
} catch {
|
||||
setError('숙제 수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/homeworks/${id}`)
|
||||
navigate('/homeworks')
|
||||
} catch {
|
||||
setError('숙제 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>숙제 수정</Typography>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="숙제 이름"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="설명"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="초기화 주기"
|
||||
value={resetType}
|
||||
onChange={(e) => setResetType(e.target.value)}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="daily">매일</MenuItem>
|
||||
<MenuItem value="weekly">매주</MenuItem>
|
||||
<MenuItem value="monthly">매월</MenuItem>
|
||||
<MenuItem value="none">없음</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="최대 클리어 횟수"
|
||||
value={clearCount}
|
||||
onChange={(e) => setClearCount(e.target.value)}
|
||||
type="number"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||
<Button variant="contained" onClick={handleUpdate}>수정</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => setOpenConfirm(true)}>삭제</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openConfirm} onClose={() => setOpenConfirm(false)}>
|
||||
<DialogTitle>숙제 삭제 확인</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>정말 이 숙제를 삭제하시겠습니까?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenConfirm(false)}>취소</Button>
|
||||
<Button onClick={handleDelete} color="error">삭제</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -36,7 +36,11 @@ export default function HomeworkList() {
|
||||
<Grid container spacing={2}>
|
||||
{homeworks.map((hw) => (
|
||||
<Grid item key={hw.id} xs={12} sm={6} md={4} {...({} as any)}>
|
||||
<Card>
|
||||
<Card
|
||||
component={Link}
|
||||
to={`/homeworks/${hw.id}/edit`}
|
||||
sx={{ cursor: 'pointer', textDecoration: 'none' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{hw.title}</Typography>
|
||||
<Typography>주기: {hw.reset_type}</Typography>
|
||||
|
||||
180
src/pages/MePage.tsx
Normal file
180
src/pages/MePage.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
TextField,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import Visibility from '@mui/icons-material/Visibility'
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../lib/api'
|
||||
|
||||
export default function MePage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const [strengthScore, setStrengthScore] = useState(0)
|
||||
const [strengthLabel, setStrengthLabel] = useState<'약함' | '보통' | '강함' | ''>('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/users/me')
|
||||
.then(res => setEmail(res.data.email || ''))
|
||||
.catch(() => setMessage('내 정보를 불러오는데 실패했습니다.'))
|
||||
}, [])
|
||||
|
||||
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 handleSubmit = async () => {
|
||||
setPasswordError('')
|
||||
setMessage(null)
|
||||
|
||||
if (!validatePassword(newPassword)) {
|
||||
setPasswordError('비밀번호는 영문자+숫자를 포함한 6~20자여야 합니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/users/me/password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
setMessage('비밀번호가 성공적으로 변경되었습니다.')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setStrengthScore(0)
|
||||
setStrengthLabel('')
|
||||
} catch {
|
||||
setMessage('비밀번호 변경에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>내 정보</Typography>
|
||||
|
||||
<TextField
|
||||
label="이메일"
|
||||
value={email ?? ''}
|
||||
fullWidth
|
||||
disabled
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="현재 비밀번호"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="새 비밀번호"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
const pw = e.target.value
|
||||
setNewPassword(pw)
|
||||
analyzePassword(pw)
|
||||
}}
|
||||
margin="normal"
|
||||
helperText="영문자+숫자 포함 6~20자, 특수문자 허용"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
error={!!passwordError}
|
||||
/>
|
||||
|
||||
{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)}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
sx={{ mt: 2 }}
|
||||
fullWidth
|
||||
disabled={!!passwordError}
|
||||
>
|
||||
비밀번호 변경
|
||||
</Button>
|
||||
|
||||
{(message || passwordError) && (
|
||||
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
|
||||
{message || passwordError}
|
||||
</Typography>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user