로그인에서부터 홈화면까지.
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"
|
"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",
|
||||||
|
|||||||
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",
|
"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
27
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
11
src/main.tsx
11
src/main.tsx
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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;
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
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