From ea1f515f9acc4cbd32689bc92482dbed699bf90c Mon Sep 17 00:00:00 2001 From: SR07 Date: Wed, 14 May 2025 18:03:56 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20=ED=99=88=ED=99=94=EB=A9=B4=EA=B9=8C?= =?UTF-8?q?=EC=A7=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- node_modules/.package-lock.json | 26 ++++ node_modules/.vite/deps/_metadata.json | 40 ++++-- package-lock.json | 27 ++++ package.json | 1 + src/App.tsx | 7 +- src/components/Layout.tsx | 98 +++++++++---- src/contexts/AuthContext.tsx | 41 ++++++ src/lib/api.ts | 34 +++++ src/main.tsx | 11 +- src/pages/CharacterList.tsx | 86 ++++++----- src/pages/Home.tsx | 49 ++++--- src/pages/Login.tsx | 45 ++++-- src/pages/RegisterCharacter.tsx | 85 +++++------ src/pages/Signup.tsx | 192 +++++++++++++++++++++++++ 14 files changed, 572 insertions(+), 170 deletions(-) create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/lib/api.ts create mode 100644 src/pages/Signup.tsx diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 46d75e5..5bfb18d 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -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", diff --git a/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json index 4c48b8c..b7b7b40 100644 --- a/node_modules/.vite/deps/_metadata.json +++ b/node_modules/.vite/deps/_metadata.json @@ -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" }, diff --git a/package-lock.json b/package-lock.json index 3352541..635e5f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 101e452..35df220 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index b8c040a..149cf46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - + } /> } /> } /> } /> } /> + } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index aa65e1f..848bcd8 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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) + const [anchorElHomework, setAnchorElHomework] = useState(null) + + const handleLogout = () => { + logout() + navigate('/login') + } + + const handleMenuOpen = ( + setter: React.Dispatch> + ) => (event: React.MouseEvent) => { + setter(event.currentTarget) + } + + const handleMenuClose = ( + setter: React.Dispatch> + ) => () => { + setter(null) + } + + const menuItems = isLoggedIn ? ( + <> + + + + + 캐릭터 목록 + 내 숙제 + + + + + 숙제 목록 + + + + + ) : ( + + ) + return ( <> - + - - {!isLoggedIn ? ( - - ) : ( - <> - - - - - - )} + {menuItems} - {children} + {children} ) } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..47826bf --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -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(undefined) + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [token, setToken] = useState(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 ( + + {children} + + ) +} + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth must be used within AuthProvider') + return context +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..ab8954f --- /dev/null +++ b/src/lib/api.ts @@ -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 diff --git a/src/main.tsx b/src/main.tsx index bef5202..872fb68 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - - - , + + + + + ) diff --git a/src/pages/CharacterList.tsx b/src/pages/CharacterList.tsx index e9f3b38..98506fd 100644 --- a/src/pages/CharacterList.tsx +++ b/src/pages/CharacterList.tsx @@ -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([]); - 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 ( -
-

내 캐릭터 목록

- {error &&

{error}

} - {characters.length === 0 ? ( -

등록된 캐릭터가 없습니다.

- ) : ( -
    - {characters.map((char) => ( -
  • -

    이름: {char.name}

    -

    서버: {char.server}

    -

    등록일: {new Date(char.created_at).toLocaleString()}

    -
  • + + + 캐릭터 목록 + + + {dummyCharacters.map((char) => ( + + + + {char.name} + 레벨: {char.level} + 직업: {char.job} + 서버: {char.server} + 전투력: {char.power} + + + ))} -
- )} -
- ); + + + + + + 캐릭터 추가 + + + + + + + ) } - -export default CharacterListPage; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ab07fa6..05c53a1 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ( + + 숙제노기에 오신 걸 환영합니다 📝 + + 이곳은 당신의 마비노기 숙제를 손쉽게 관리하는 웹 앱입니다. + + + + ) + } + + // ✅ 로그인 상태일 때 기존 숙제 카드 화면 출력 return ( 이번 주 내 숙제 - + + <> {dummyHomeworks.map(hw => ( - - - - [{hw.character}] {hw.title} - 상태: {hw.status} - - - - - - + + [{hw.character}] {hw.title} + 상태: {hw.status} + ))} - + + ) } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 1807747..0dab2b4 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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() { 로그인 - + setUsername(e.target.value)} + value={email} + onChange={(e) => setEmail(e.target.value)} /> setPassword(e.target.value)} /> - + {error && {error}} diff --git a/src/pages/RegisterCharacter.tsx b/src/pages/RegisterCharacter.tsx index a462f72..cf936e6 100644 --- a/src/pages/RegisterCharacter.tsx +++ b/src/pages/RegisterCharacter.tsx @@ -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 ( -
-
-

캐릭터 등록

- setServer(e.target.value)} - placeholder="서버" - className="w-full mb-3 px-4 py-2 border rounded-md" - /> - setName(e.target.value)} - placeholder="캐릭터 이름" - className="w-full mb-3 px-4 py-2 border rounded-md" - /> - - {message &&

{message}

} -
-
- ); + + + + 캐릭터 등록 + + + setName(e.target.value)} + fullWidth + /> + setServer(e.target.value)} + fullWidth + /> + + + + + ) } - -export default RegisterCharacterPage; - diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx new file mode 100644 index 0000000..714494c --- /dev/null +++ b/src/pages/Signup.tsx @@ -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 ( + + + + 회원가입 + + + + setEmail(e.target.value)} + onKeyUp={(e) => { + if (e.key === 'Enter') handleSignup() + else checkEmailDuplicate(email) + }} + error={!!emailError} + helperText={emailError || (emailStatus === 'available' ? '사용 가능한 이메일입니다.' : ' ')} + /> + + { + const pw = e.target.value + setPassword(pw) + analyzePassword(pw) + }} + onKeyUp={(e) => e.key === 'Enter' && handleSignup()} + helperText="영문자+숫자 포함 6~20자, 특수문자 허용" + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} edge="end"> + {showPassword ? : } + + + ), + }} + /> + + {strengthLabel && ( + + + 비밀번호 강도: {strengthLabel} + + + + )} + + setConfirmPassword(e.target.value)} + onKeyUp={(e) => e.key === 'Enter' && handleSignup()} + /> + + + + {error && {error}} + + + + ) +}