This commit is contained in:
SR07 2025-05-26 18:18:14 +09:00
parent c18f705286
commit 61ddda7fe6
8 changed files with 469 additions and 27 deletions

View File

@ -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: {
@ -29,7 +32,7 @@ function App() {
<Layout key={isLoggedIn ? 'in' : 'out'}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/signup" element={<Signup />} />
<Route path="/signup" element={<Signup />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/characters/register" element={<RegisterCharacter />} />
<Route path="/characters" element={<CharacterList />} />
@ -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>

View File

@ -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>
</>
) : (
<>

View File

@ -1,7 +1,7 @@
import axios from 'axios'
const api = axios.create({
baseURL: 'https://api.biryu2000.kr',
baseURL: 'http://localhost:8000',
})
// 요청 시 토큰 자동 추가

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

View File

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

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

View File

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