What to do?
React로 Front-end 프로젝트 생성하기
ⓐ 네비게이션바 ⓑ 회원가입 ⓒ 로그인 ⓓ포스팅 List ⓔ 포스팅 작성
세팅
프로젝트 생성
- front-end라는 이름의 react 프로젝트 생성
npx create-react-app front-end
라이브러리 설치
- recoil : 상태관리
- react-router-dom : 라우팅
- material ui : 디자인
npm install recoil
npm install react-router-dom # 최신버전(v6)
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
index.js
App태그를 RecoilRoot 태그, BrowserRouter 태그로 감싸줌
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<RecoilRoot>
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
</RecoilRoot>
);
reportWebVitals();
전역 State 정의
recoil/index.js
export * from "./user";
recoil/user.js
import { atom } from "recoil";
export const userState = atom({
key : "userState",
default : {
nickname:null,
token:null
}
})
App.js
- 라우팅 경로 지정
const App = () => {
return (
<div className="App">
<Nav/>
<Routes>
<Route path="/register" element={<Register/>}></Route>
<Route path="/login" element={<Login/>}></Route>
<Route path="/post" element={<Post/>}></Route>
<Route path="/post/:id" element={<DetailPost/>}></Route>
<Route path="/post/write" element={<WritePost/>}></Route>
</Routes>
</div>
);
}
export default App;
Material UI
css파일 만지면서 한땀한땀 디자인을 넣기는 너무 빡세다.
그냥 material ui에서 코드 북붙하고 수정해서 만들었다.
네비게이션바
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Menu from '@mui/material/Menu';
import MenuIcon from '@mui/icons-material/Menu';
import Container from '@mui/material/Container';
import Button from '@mui/material/Button';
import MenuItem from '@mui/material/MenuItem';
import AdbIcon from '@mui/icons-material/Adb';
import { Link } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { userState } from '../../recoil/user';
import { useEffect, useState } from 'react';
import axios from 'axios';
// Custom Settings
const appName = "Karma"
const pagesNotLogined = [
{label:"login", link:"/login"},
{label:"register", link:"/register"},
]
const pagesLogined = [
{label:"post", link:"/post"}
]
// Component to export
const Nav = () => {
const [user, setUser] = useRecoilState(userState);
const [pages, setPages] = useState([{}]);
const [anchorElNav, setAnchorElNav] = useState(null);
useEffect(()=>{
// localStorage에서 token값 꺼내기
const token = localStorage.getItem("token");
// token이 없으면 초기화
if (!token){
setPages(pagesNotLogined);
setUser({nickname:null, token: null});
localStorage.removeItem("token");
return;
}
// token이 유효한지 요청
const endPoint = "/api/v1/user/nickname";
axios.get(endPoint, {
headers:{
Authorization : localStorage.getItem("token")
}
}).then((res)=>{
return res.data.result
}).then((nickname)=>{
setPages(pagesLogined);
setUser({token, nickname});
}).catch((err)=>{
console.log(err);
})
}, [])
const handleOpenNavMenu = (event) => {
setAnchorElNav(event.currentTarget);
};
const handleCloseNavMenu = () => {
setAnchorElNav(null);
};
return (
<AppBar position="static">
<Container maxWidth="xl">
<Toolbar disableGutters>
<AdbIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
<Link to="/">
<Typography
variant="h6"
noWrap
sx={{
mr: 2,
display: { xs: 'none', md: 'flex' },
fontFamily: 'monospace',
fontWeight: 700,
letterSpacing: '.3rem',
color: 'white',
textDecoration: 'none',
}}>
{appName}
</Typography>
</Link>
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleOpenNavMenu}
color="inherit"
>
<MenuIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={Boolean(anchorElNav)}
onClose={handleCloseNavMenu}
sx={{
display: { xs: 'block', md: 'none' },
}}
>
{pages.map((page, idx) => (
<Link to={page.link} key={idx}>
<MenuItem onClick={handleCloseNavMenu}>
<Typography textAlign="center">{page.label}</Typography>
</MenuItem>
</Link>
))}
</Menu>
</Box>
<AdbIcon sx={{ display: { xs: 'flex', md: 'none' }, mr: 1 }} />
<Typography
variant="h5"
noWrap
component="a"
href=""
sx={{
mr: 2,
display: { xs: 'flex', md: 'none' },
flexGrow: 1,
fontFamily: 'monospace',
fontWeight: 700,
letterSpacing: '.3rem',
color: 'inherit',
textDecoration: 'none',
}}
>
{appName}
</Typography>
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
{pages.map((page, idx) => (
<Link to={page.link} sx={{color: 'white'}}>
<Button
key={idx}
onClick={handleCloseNavMenu}
sx={{ my: 2, color: 'white', display: 'block' }}>
{page.label}
</Button>
</Link>
))}
</Box>
<Box sx={{ flexGrow: 0 }}>
{
user.nickname
? `${user.nickname} 님 환영합니다`
: null
}
</Box>
</Toolbar>
</Container>
</AppBar>
);
}
export default Nav;
회원가입
auth/register/CustomValidate.js
- 정규표현식을 사용해 유저 입력 값을 체크
const customValidate = (type, value) => {
if (value === ""){
return false;
}
let regex = "";
switch(type){
case "email":
regex = /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/;
case "password":
regex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,25}$/;
case "username":
regex = /^[가-힣a-zA-Z0-9]+$/
case "nickname":
regex = /^[가-힣a-zA-Z0-9]+$/
}
return regex.test(value);
}
export default customValidate;
auth/register/index.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom";
import axios from 'axios';
import {
Avatar,
Button,
CssBaseline,
TextField,
FormControl,
FormHelperText,
Grid,
Box,
Typography,
Container,
} from '@mui/material/';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import validateUtil from './customValidate';
import styled from 'styled-components';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import Tooltip from '@mui/material/Tooltip';
import { VisibilityRounded } from '@mui/icons-material';
// ---------- styled components ---------- //
const FormHelperTexts = styled(FormHelperText)`
width: 100%;
padding-left: 16px;
font-weight: 700 !important;
color: #d32f2f !important;
`;
const Boxs = styled(Box)`
padding-bottom: 40px !important;
`;
const Register = () => {
const endPoint = "/api/v1/user/register";
const navigator = useNavigate();
// ---------- states ---------- //
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [nickname, setNickname] = useState('');
const [password, setPassoword] = useState('');
const [passwordConfirm, setPassowordConfirm] = useState('');
const theme = createTheme();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isPasswordConfirmVisible, setIsPasswordConfirmVisible] = useState(false);
const [isValid, setIsValid] = useState(false);
const [registerErrorMessage, setRegisterErrorMessage] = useState('');
// ---------- hook ---------- //
useEffect(()=>{
if (email==="" || password === "" || nickname === "" || username === ""){
setRegisterErrorMessage("");
setIsValid(false);
} else if (validateUtil("email", email)){
setRegisterErrorMessage("이메일이 유효하지 않습니다.");
setIsValid(false);
} else if (validateUtil("password", password)){
setRegisterErrorMessage("패스워드가 유효하지 않습니다 (숫자+영문자+특수문자 8자리 이상).");
setIsValid(false);
} else if (password !== passwordConfirm) {
setRegisterErrorMessage("비밀번호와 확인값이 서로 일치하지 않습니다.")
setIsValid(false);
} else {
setRegisterErrorMessage("");
setIsValid(true);
}
}, [username, email, password, passwordConfirm])
// ---------- handlers ---------- //
const handleUsername = (e)=>{
const input = e.target.value;
setUsername(input);
}
const handleNickname = (e)=>{
const input = e.target.value;
setNickname(input);
}
const handlePassword = (e)=>{
const input = e.target.value;
setPassoword(input);
}
const handlePasswordConfirm = (e)=>{
const input = e.target.value;
setPassowordConfirm(input);
}
const handleEmail = (e)=>{
const input = e.target.value;
setEmail(input);
}
const handleIsPasswordVisible = (e)=>{
e.preventDefault();
setIsPasswordVisible(!isPasswordVisible)
}
const handleIsPasswordConfirmVisible = (e)=>{
e.preventDefault();
setIsPasswordConfirmVisible(!isPasswordConfirmVisible)
}
const handleSubmit = async (e) => {
e.preventDefault();
await axios
.post(endPoint, {email, username, nickname, password})
.then((res) => {
navigator("/login");
})
.catch((err) => {
console.log(err);
switch (err.response.data.statusCode){
case "DUPLICATED_EMAIL":
setRegisterErrorMessage(email + '은 이미 존재하는 이메일입니다...');
break;
case "DUPLICATED_USERNAME":
setRegisterErrorMessage(username + '은 이미 존재하는 유저명입니다 ...');
break;
case "DUPLICATED_NICKNAME":
setRegisterErrorMessage(nickname + '이미 존재하는 닉네임입니다...');
break;
default:
setRegisterErrorMessage('회원가입에 실패하였습니다...');
}
});
};
return (
<ThemeProvider theme={theme}>
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }} />
<Typography component="h1" variant="h5">회원가입</Typography>
<Boxs component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}>
<FormControl component="fieldset" variant="standard">
<Grid container spacing={2}>
{/* ----- 이메일 ----- */}
<Grid item xs={12}>
<TextField
required
autoFocus
fullWidth
type="email"
label="이메일 주소"
value={email}
onChange={handleEmail}
/>
</Grid>
{/* ----- 유저명 ----- */}
<Grid item xs={12}>
<Tooltip title="로그인 시 사용할 유저명을 입력해주세요">
<TextField
required
fullWidth
label="유저명"
value={username}
onChange={handleUsername}/>
</Tooltip>
</Grid>
{/* ----- 닉네임 ----- */}
<Grid item xs={12}>
<TextField
required
fullWidth
label="닉네임"
value={nickname}
onChange={handleNickname}/>
</Grid>
{/* ----- 비밀번호 ----- */}
<Grid item xs={10}>
<Tooltip title="숫자+영문자+특수문자 8자리 이상으로 작명해주세요">
<TextField
required
fullWidth
type={isPasswordVisible?"text":"password"}
label="비밀번호"
value={password}
onChange={handlePassword}/>
</Tooltip>
</Grid>
<Grid item xs={1}>
<Button onClick={handleIsPasswordVisible}>
{
isPasswordVisible?<VisibilityOffIcon/>:<VisibilityRounded/>
}
</Button>
</Grid>
{/* ----- 비밀번호 확인 ----- */}
<Grid item xs={10}>
<Tooltip title="비밀번호를 다시한번 입력해주세요">
<TextField
required
fullWidth
type={isPasswordConfirmVisible?"text":"password"}
label="비밀번호 재입력"
value={passwordConfirm}
onChange={handlePasswordConfirm}/>
</Tooltip>
</Grid>
<Grid item xs={1}>
<Button onClick={handleIsPasswordConfirmVisible}>
{
isPasswordConfirmVisible?<VisibilityOffIcon/>:<VisibilityRounded/>
}
</Button>
</Grid>
</Grid>
{/* ----- 회원가입 버튼 ----- */}
<Button
disabled = {!isValid}
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
size="large">
회원가입
</Button>
</FormControl>
<FormHelperTexts>{registerErrorMessage}</FormHelperTexts>
</Boxs>
</Box>
</Container>
</ThemeProvider>
);
};
export default Register;
로그인
auth/login.index.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom";
import axios from 'axios';
import {
Avatar,
Button,
CssBaseline,
TextField,
FormControl,
FormHelperText,
Grid,
Box,
Typography,
Container,
} from '@mui/material/';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import styled from 'styled-components';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import Tooltip from '@mui/material/Tooltip';
import { VisibilityRounded } from '@mui/icons-material';
import { useRecoilState } from 'recoil';
import { userState } from '../../../recoil/user';
// ---------- styled components ---------- //
const FormHelperTexts = styled(FormHelperText)`
width: 100%;
padding-left: 16px;
font-weight: 700 !important;
color: #d32f2f !important;
`;
const Boxs = styled(Box)`
padding-bottom: 40px !important;
`;
const Login = () => {
const endPoint = "/api/v1/user/login";
const navigator = useNavigate();
// ---------- states ---------- //
const [username, setUsername] = useState('');
const [password, setPassoword] = useState('');
const theme = createTheme();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isValid, setIsValid] = useState(false);
const [loginErrorMessage, setRegisterErrorMessage] = useState('');
const [user, setUser] = useRecoilState(userState);
// ---------- hook ---------- //
useEffect(()=>{
if (username === ""){
setRegisterErrorMessage("유저명을 입력해주세요");
setIsValid(false);
} else if (password === ""){
setRegisterErrorMessage("패스워드를 입력해주세요");
setIsValid(false);
} else {
setRegisterErrorMessage("");
setIsValid(true);
}
}, [username,password])
// ---------- handlers ---------- //
const handleUsername = (e)=>{
const input = e.target.value;
setUsername(input);
}
const handlePassword = (e)=>{
const input = e.target.value;
setPassoword(input);
}
const handleIsPasswordVisible = (e)=>{
e.preventDefault();
setIsPasswordVisible(!isPasswordVisible)
}
const handleSubmit = async (e) => {
e.preventDefault();
await axios
.post(endPoint, {username, password})
// 로그인 성공시
.then((res) => {
const token = `Bearer ${res.data.result}`;
// 전역변수 user 세팅
setUser({...user, token})
// localstorage에 token 저장
localStorage.setItem("token", token);
// 포스팅 페이지로 이동
window.location.href="/post"
})
// 로그인 실패시
.catch((err) => {
console.log(err);
switch (err.response.data.statusCode){
case "USERNAME_NOT_FOUND":
setRegisterErrorMessage(username + '은 존재하지 않는 유저명입니다...');
break;
case "INVALID_PASSWORD":
setRegisterErrorMessage('비밀번호가 틀렸습니다...');
break;
default:
setRegisterErrorMessage('로그인에 실패하였습니다...');
}
});
};
return (
<ThemeProvider theme={theme}>
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }} />
<Typography component="h1" variant="h5">로그인</Typography>
<Boxs component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}>
<FormControl component="fieldset" variant="standard">
<Grid container spacing={2}>
{/* ----- 유저명 ----- */}
<Grid item xs={12}>
<TextField
required
fullWidth
label="유저명"
value={username}
onChange={handleUsername}/>
</Grid>
{/* ----- 비밀번호 ----- */}
<Grid item xs={10}>
<Tooltip title="비밀번호를 입력해주세요">
<TextField
required
fullWidth
type={isPasswordVisible?"text":"password"}
label="비밀번호"
value={password}
onChange={handlePassword}/>
</Tooltip>
</Grid>
<Grid item xs={1}>
<Button onClick={handleIsPasswordVisible}>
{
isPasswordVisible?<VisibilityOffIcon/>:<VisibilityRounded/>
}
</Button>
</Grid>
</Grid>
{/* ----- 로그인 버튼 ----- */}
<Button
disabled = {!isValid}
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
size="large">
로그인
</Button>
</FormControl>
<FormHelperTexts>{loginErrorMessage}</FormHelperTexts>
</Boxs>
</Box>
</Container>
</ThemeProvider>
);
};
export default Login;
포스팅 List
post/index.js
import { Box, Container } from "@mui/system";
import axios from "axios";
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, Typography } from "@mui/material";
import CreateIcon from '@mui/icons-material/Create';
import DynamicFeedIcon from '@mui/icons-material/DynamicFeed';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
const PostList = ()=>{
const endPoint = '/api/v1/post'
const [page, setPage] = useState(0);
const [posts, setPosts] = useState([]);
const navigator = useNavigate();
useEffect(()=>{
axios.get(endPoint, {
headers:{
Authorization:localStorage.getItem("token")
}
}).then((res)=>{
return res.data.result.content
}).then((c)=>{
setPosts(c);
})
.catch((err)=>{
console.log(err);
})
}, [page])
const handleGoToWritePage = (e) =>{
navigator("/post/write")
}
return (
<>
<Container>
<Box sx={{marginTop:'5vh', display:'flex', justifyContent:'space-between', alignContent:'center'}}>
<Typography variant="h5" component="h5">
<DynamicFeedIcon/> 포스팅
</Typography>
<Box>
<Button variant="contained" color="success" onClick={handleGoToWritePage} sx={{marginRight:'10px'}}>
<CreateIcon sx={{marginRight:'10px'}}/>포스팅 쓰기
</Button>
</Box>
</Box>
{
posts.map((p, i)=>{
return (
<Box sx={{marginTop:'5vh'}} key={i}>
<PostingCard post={p}/>
</Box>
)
})
}
</Container>
</>
)
}
const PostingCard = ({post}) => {
const endPoint = `/post/${post.id}`;
return (
<Card sx={{ minWidth: 275 }}>
<CardContent>
<CardActions sx={{justifyContent:"space-between"}}>
{/* 제목 */}
<Typography variant="h5" component="span">
<Link to={endPoint}> {post.title}</Link>
</Typography>
{/* 닉네임(작성자) */}
<Typography variant="span" component="span" color="text.secondary">
{post.nickname}
</Typography>
</CardActions>
<CardActions sx={{justifyContent:"space-between"}}>
{/* 본문 - 100글자까지만 보여주고 ... 붙이기 */}
<Box sx={{padding:'1vh'}}>
<Typography variant="body2">{post.content.slice(0, 100)}...</Typography>
</Box>
{/* 닉네임(작성자) */}
<Typography variant="span" component="span" color="text.secondary">
{post.createdAt}
</Typography>
</CardActions>
{/* TODO : 페이징 기능 */}
</CardContent>
</Card>
);
}
export default PostList;
포스팅 작성
post/write/index.js
import axios from "axios";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import CreateIcon from '@mui/icons-material/Create';
import { Button, TextField, Typography } from "@mui/material";
import { Box, Container } from "@mui/system";
import DynamicFeedIcon from '@mui/icons-material/DynamicFeed';
import UploadIcon from '@mui/icons-material/Upload';
const WritePost = () => {
const endPoint = "/api/v1/post";
const navigator = useNavigate();
// ------ state ------
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isLoading, setIsLoading] = useState(false);
// ------ handler ------
const handleTitle = (e) =>{
setTitle(e.target.value.slice(0, 100));
}
const handleContent = (e) => {
setContent(e.target.value.slice(0, 2000));
}
const handleGoToPostPage = (e) => {
navigator("/post")
}
const handleSubmit = async (e) =>{
e.preventDefault();
setIsLoading(true);
await axios.post(
endPoint,
{title, content},
{
headers:{
Authorization : localStorage.getItem("token")
}
}
).then((res)=>{
navigator("/post");
}).catch((err)=>{
alert("포스팅 업로드에 실패하였습니다 \n" + (err.response.data.resultCode??"알수 없는 서버 오류"));
console.log(err);
}).finally(()=>{
setIsLoading(false);
});
}
return (
<>
<Container>
<Box sx={{marginTop:'5vh', display:'flex', justifyContent:'space-between', alignContent:'center'}}>
<Typography variant="h5" component="h5">
<CreateIcon/> 포스팅 작성하기
</Typography>
<Box>
<Button variant="contained" color="success" onClick={handleGoToPostPage} sx={{marginRight:'10px'}}>
<DynamicFeedIcon sx={{marginRight:'10px'}}/>포스팅 페이지로
</Button>
<Button variant="contained" color="error" type="submit" onClick={handleSubmit} disabled={isLoading}>
<UploadIcon sx={{marginRight:'10px'}}/>제출
</Button>
</Box>
</Box>
<Box sx={{marginTop:'5vh'}}>
<Box sx={{alignItems:'center'}}>
<Typography variant="h6" component="h6" sx={{display:'inline', marginRight:'20px'}}>제목</Typography>
<Typography variant="span" component="span" sx={{color:'gray'}}>
({title.length} / 100)
</Typography>
</Box>
<TextField
sx={{width:'100%'}}
onChange={handleTitle}
variant="outlined"
color="warning"
maxRows={1}
value={title}
focused
/>
</Box>
<Box sx={{marginTop:'5vh'}}>
<Box sx={{alignItems:'center'}}>
<Typography variant="h6" component="h6" sx={{display:'inline', marginRight:'20px'}}>본문</Typography>
<Typography variant="span" component="span" sx={{color:'gray'}}>
({content.length} / 2000)
</Typography>
</Box>
<TextField
sx={{width:'100%', height:'50vh'}}
onChange={handleContent}
variant="outlined"
color="warning"
value={content}
multiline
focused
/>
</Box>
</Container>
</>
)
}
export default WritePost;
'Java > Spring' 카테고리의 다른 글
[Spring] 간단한 SNS 만들기 #6 (0) | 2023.01.15 |
---|---|
[Spring] 간단한 SNS 만들기 #5 (0) | 2023.01.15 |
[Spring] 간단한 SNS 만들기 #3 (0) | 2022.12.22 |
[Spring] 간단한 SNS 만들기 #2 (0) | 2022.12.22 |
[Spring] 간단한 SNS 만들기 #1 (0) | 2022.12.22 |