React - 커뮤니티 프로젝트
•
진행 과정 정리
[누구나 볼 수 있음]
홈 화면 /
타임라인 /post [게시물 리스트]
검색 /search [게시물 검색]
프로필 /profile/:id [특정 사람의 프로필]
404 NOT FOUND /*
[회원만 - ]
마이프로필 /profile [내 프로필]
[게스트만]
회원가입 /signup
로그인 /login
Plain Text
복사
•
emotion style 사용
•
mui.com 에 접속하여 material ui 설치
yarn add @mui/material @emotion/react @emotion/styled
Shell
복사
•
carousel 패키지 설치
yarn add react-responsive-carousel
Shell
복사
•
폴더 / 파일 구성 정리
◦
api 폴더
▪
services 폴더
•
posts.js
import api from "../api";
export const postApi = {
getPosts: () => api.get("/posts"),
postPosts: (post) => api.post("/posts", post)
}
JavaScript
복사
•
users.js
import api from "../api";
export const userApi = {
getUser: (userId) => api.get(`/users/${userId}`),
loginUser: (email, password) => api.get(`/users?email=${email}&password=${password}`),
getUserByEmail: (email) => api.get(`/users?email=${email}`),
postUser: (user) => api.post('/users', user)
}
JavaScript
복사
◦
components 폴더
▪
layouts 폴더
•
Header.jsx
import styled from '@emotion/styled'
import { Link } from 'react-router-dom';
import { IoBagCheckOutline } from "react-icons/io5";
const Header = () => {
const loginUser = localStorage.getItem("loginUser");
return (
<StyledHeader>
<Link to="/"><IoBagCheckOutline/></Link>
<nav>
<Link to="/">HOME</Link>
<Link to="/post">TIMELINE</Link>
<Link to="/search">SEARCH</Link>
{/* 로그인 후에는 이 버튼은 로그아웃으로 변경 */}
{
loginUser ?
<>
<Link to="/info">{loginUser}</Link>
</> :
<Link to="/login">LOGIN</Link>
}
</nav>
</StyledHeader>
);
}
const StyledHeader = styled.header`
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background-color: black;
color: #fff;
& > a {
color: #fff;
text-decoration: none;
font-size: 2.5rem;
}
nav {
display: flex;
a {
font-size: 2rem;
color: #fff;
text-decoration: none;
margin: 0 1rem;
&:hover {
text-decoration: underline;
}
}
}
`
export default Header;
JavaScript
복사
•
Layout.jsx
import { BrowserRouter } from "react-router-dom";
import Header from "./Header";
import Main from "./Main";
const Layout = ({children}) => {
return (
<>
<BrowserRouter>
<Header />
<Main>
{children}
</Main>
</BrowserRouter>
</>
);
}
export default Layout;
JavaScript
복사
•
Main.jsx
import Grid from "@mui/material/Grid"
const Main = ({children}) => {
return (
<Grid
container
my={3}
spacing={1}
direction={"column"}
alignItems={"center"}
>
{children}
</Grid>
);
}
export default Main;
JavaScript
복사
▪
pages 폴더
•
Home.jsx
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
const Home = () => {
const [detail, setDetail] = useState('');
const navigate = useNavigate();
const handlePageMove = () => {
// Link와 같은 동작
// navigate('/')
// navigate({
// pathname: '/'
// })
navigate({
pathname: '/',
search: `detail=${detail}`
})
}
return (
<>
<h1>홈</h1>
<input type='text' value={detail} onChange={(e) => setDetail(e.target.value)}/>
<Link to='/'>이동</Link>
<a href='/'>이동</a>
<button onClick={handlePageMove}>이동</button>
<h2>현재 state: {detail}</h2>
</>
);
}
export default Home;
JavaScript
복사
•
Login.jsx
import { useState } from "react";
import styled from '@emotion/styled';
import useInputs from "../../hooks/useInputs";
import axios from "axios";
import { Button, FormControl, OutlinedInput } from "@mui/material";
const Login = () => {
const { form, handleChange, handleReset } = useInputs({ email: '', password: '' })
const { email, password } = form;
const handleLogin = async () => {
// 가짜 데이터이기 때문에, get 방식으로 로그인 진행 (실제로는 post 방식)
const url = `${process.env.REACT_APP_SERVER_ADDR}/users?email=${email}&password=${password}`;
try {
const res = await axios.get(url);
if (res.status === 200 && res.data.length == 1) {
// 로그인
console.log(res.data[0]);
localStorage.setItem("loginUser", res.data[0].email);
} else {
alert("로그인 불가능")
}
} catch (error) {
console.error(error);
}
}
return (
<>
<h1>로그인 화면</h1>
<form>
<FormControl sx={{ width: '100%', display: 'block' }}>
<OutlinedInput type="email" name="email" value={email} onChange={handleChange} />
</FormControl>
<FormControl sx={{ width: '100%', display: 'block' }}>
<OutlinedInput type="password" name="password" value={password} onChange={handleChange} />
</FormControl>
<Button
variant='contained'
type="button"
sx={{ display: 'block', width: '100%', marginTop: '10px' }}
onClick={handleLogin}
>
로그인
</Button>
</form>
</>
);
}
export default Login;
JavaScript
복사
•
MyProfile.jsx
const MyProfile = () => {
return (
<>
<h1>MyProfile</h1>
</>
);
}
export default MyProfile;
JavaScript
복사
•
NotFound.jsx
const NotFound = () => {
return (
<>
<h1>Not Found</h1>
</>
);
}
export default NotFound;
JavaScript
복사
•
Profile.jsx
const Profile = () => {
return (
<>
<h1>Profile</h1>
</>
);
}
export default Profile;
JavaScript
복사
•
Search.jsx
const Search = () => {
return (
<>
<h1>Search</h1>
</>
);
}
export default Search;
JavaScript
복사
•
SignUp.jsx
import styled from '@emotion/styled';
import useInputs from "../../hooks/useInputs";
import axios from "axios";
import { useState } from "react";
import { Box, Button, FormControl, FormGroup, IconButton, InputAdornment, InputLabel, OutlinedInput, TextField, Typography } from '@mui/material';
import { IoEye } from "react-icons/io5";
import { IoMdEyeOff } from "react-icons/io";
import { userApi } from '../../api/services/users';
const SignUp = () => {
const { form, handleChange, handleReset } = useInputs({
email: "",
nickname: "",
password: "",
password_chk: ""
});
const { email, nickname, password, password_chk } = form;
const [isDuplicate, setIsDuplicate] = useState(false);
const [errors, setErrors] = useState({});
const [showPassword, setShowPassword] = useState(false);
const handleTogglePassword = () => setShowPassword(!showPassword);
const handleMouseDownPassword = (e) => e.preventDefault();
const handleDuplicate = async () => {
// email 중복 체크
if (email.trim()) {
try {
const res = await userApi.getUserByEmail(email);
if (!res.data.length) {
setErrors({});
setIsDuplicate(true);
alert("사용 가능한 이메일입니다.");
} else {
setErrors({ email: "이미 존재하는 이메일입니다." });
setIsDuplicate(false);
}
} catch (error) {
console.error(error);
}
}
}
const resetDuplicate = (e) => {
setIsDuplicate(false);
handleChange(e);
}
const validate = () => {
let isValid = true;
const newErrors = {};
if (!isDuplicate) {
newErrors.email = `중복 체크 해주세요`;
}
// password_chk 일치 여부 체크
if (form.password !== form.password_chk) {
isValid = false;
newErrors.password_chk = `비밀번호 불일치입니다`;
}
for (const [key, value] of Object.entries(form)) {
// 빈값 여부 체크
if (!value.trim()) {
isValid = false;
newErrors[key] = `해당 값을 입력해주세요`;
}
}
console.log(newErrors);
setErrors(newErrors);
return isValid;
}
const handleSignUp = async (e) => {
e.preventDefault();
if (isDuplicate && validate()) {
const user = { email, nickname, password };
try {
const res = await userApi.postUser(user);
if (res.status === 201) {
alert('회원가입 완료');
} else {
throw new Error("회원가입 실패");
}
} catch (error) {
console.error(error);
} finally {
handleReset();
}
}
}
return (
// 회원가입할 때, { email, nickname, password, password_chk}
<>
<Typography variant='h4'>회원가입</Typography>
<Box
component={"form"}
my={4}
p={2}
borderRadius={4}
boxShadow={'0 0 4px grey'}
width={"fit-content"}
sx={{
margin: '0 auto',
'& > :not(style)': { m: 1, width: '100%' }
}}
noValidate
autoComplete='off'
>
<FormGroup sx={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<TextField
label="이메일"
variant='outlined'
autoFocus
required
type='email' id='email' name='email' value={email} onChange={resetDuplicate}
error={errors.email ? true : false}
helperText={errors.email}
/>
<Button onClick={handleDuplicate} variant='contained'>중복 확인</Button>
</FormGroup>
<TextField
label="닉네임"
variant='outlined'
sx={{ display: 'block' }}
required
id='nickname' name='nickname' value={nickname} onChange={handleChange}
error={errors.nickname ? true : false}
helperText={errors.nickname}
/>
<FormControl sx={{ m: 1, width: '100%', display: 'block' }} variant='outlined'>
<InputLabel sx={errors.password && { color: '#d32f2f' }} htmlFor="password">비밀번호 *</InputLabel>
<OutlinedInput
type={showPassword ? 'text' : 'password'} id='password' name='password' value={password} onChange={handleChange}
required
autoComplete='new-password'
label='비밀번호 *'
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={handleTogglePassword}
onMouseDown={handleMouseDownPassword}
edge="end"
>
{showPassword ? <IoEye /> : <IoMdEyeOff />}
</IconButton>
</InputAdornment>
}
error={errors.password ? true : false}
/>
</FormControl>
<FormControl sx={{ m: 1, width: '100%', display: 'block' }} variant='outlined'>
<InputLabel sx={errors.password_chk && { color: '#d32f2f' }} htmlFor="password_chk">비밀번호 확인 *</InputLabel>
<OutlinedInput
type={showPassword ? 'text' : 'password'} id='password_chk' name='password_chk' value={password_chk} onChange={handleChange}
required
autoComplete='new-password'
label='비밀번호 확인 *'
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={handleTogglePassword}
onMouseDown={handleMouseDownPassword}
edge="end"
>
{showPassword ? <IoEye /> : <IoMdEyeOff />}
</IconButton>
</InputAdornment>
}
error={errors.password_chk ? true : false}
/>
<ErrorMsg>{errors.password || errors.password_chk}</ErrorMsg>
</FormControl>
<Button onClick={handleSignUp}>가입</Button>
</Box>
</>
);
}
const ErrorMsg = styled.p`
color: #d32f2f;
font-family: "Roboto","Helvetica","Arial",sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
letter-spacing: 0.03333em;
text-align: left;
margin: 3px 14px 0 14px;
`
export default SignUp;
JavaScript
복사
•
TimeLine.jsx
import { Typography } from "@mui/material";
import { useEffect, useReducer } from "react";
import { postReducer } from "../../hooks/reducer";
import PostList from "../timeline/PostList";
import PostWrite from "../timeline/PostWrite";
import { postApi } from "../../api/services/posts";
import { userApi } from "../../api/services/users";
const TimeLine = () => {
const [posts, dispatch] = useReducer(postReducer, []);
const getPosts = async () => {
try {
const res = await postApi.getPosts();
const posts = res.data;
const postList = [];
for (let post of posts) {
const res2 = await userApi.getUser(post.userId);
post.user = res2.data;
postList.push(post);
}
if (res.status === 200) {
dispatch({ type: "SET_POSTS", payload: postList });
}
} catch (error) {
console.error(error);
}
}
useEffect(() => {
getPosts();
}, []);
return (
<>
<Typography variant='h4'>타임라인</Typography>
<PostWrite dispatch={dispatch} />
<PostList posts={posts} />
</>
);
}
export default TimeLine;
JavaScript
복사
▪
timeline 폴더
•
PostList.jsx
/* eslint-disable jsx-a11y/alt-text */
import { Button, List, ListItem, ListItemText, Stack } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { Carousel } from "react-responsive-carousel";
import "react-responsive-carousel/lib/styles/carousel.min.css"; // requires a loader
const PostList = ({ posts }) => {
const navigate = useNavigate();
const goToProfile = (userId) => {
navigate(`/profile/${userId}`)
}
return (
<>
<List sx={{ minWidth: "300px" }}>
{
posts.map(p => (
<ListItem
key={p.id}
divider
alignItems="flex-start"
sx={{flexDirection: "column"}}
>
<ListItemText>
<Button
size="small"
variant="outlined"
onClick={() => goToProfile(p.userId)}
>
{p.user.nickname}
</Button>
</ListItemText>
<ListItemText>
<span>
{p.contents}
</span>
</ListItemText>
<Carousel
showArrays centerMode infiniteLoop dynamicHeight
showThumbs={false} width={"50%"}
>
{
p.img && p.img.map((img, idx) => (
<Stack key={idx}>
<img src={`${process.env.REACT_APP_SERVER_ADDR}${img}`}/>
</Stack>
))
}
</Carousel>
{/* <img src={`${process.env.REACT_APP_SERVER_ADDR}${p.img}`} /> */}
</ListItem>
))
}
</List>
</>
);
}
export default PostList;
JavaScript
복사
•
PostWrite.jsx
import { Button, Grid, InputBase, TextField } from "@mui/material";
import useInputs from "../../hooks/useInputs";
import axios from "axios";
import { postApi } from "../../api/services/posts";
import { userApi } from "../../api/services/users";
const PostWrite = ({ dispatch }) => {
const { form, handleChange, handleReset } = useInputs({ contents: "" });
const handleAddPost = async () => {
let formData = {
"userId": "1",
"like": 0,
"img": [],
...form
};
try {
const res1 = await userApi.getUser(formData.userId);
if (res1.status === 200 && res1.data) {
formData.user = res1.data;
} else {
throw new Error("알 수 없는 에러")
}
const res2 = await postApi.postPosts(formData);
if (res2.status === 201) {
console.log(res2.data);
dispatch({ type: "ADD_POST", payload: res2.data });
}
} catch (error) {
console.error(error);
}
}
return (
<form>
<TextField fullWidth id="contents" name="contents" value={form.contents} onChange={handleChange} />
<InputBase type='file' accept="image/*" />
<Button onClick={handleAddPost}>글쓰기</Button>
</form>
);
}
export default PostWrite;
JavaScript
복사
◦
hooks
▪
reducer.jsx
export const postReducer = (posts, action) => {
switch (action.type) {
case `SET_POSTS`:
return action.payload;
case `ADD_POST`:
return [...posts, action.payload];
}
}
JavaScript
복사
▪
useInputs.jsx
import { useState } from "react";
const useInputs = (initForm) => {
const [form, setForm] = useState(initForm);
const handleChange = (e) => {
const { name, value } = e.target;
setForm(form => ({ ...form, [name]: value }));
}
const handleReset = () => {
setForm(initForm)
}
return { form, handleChange, handleReset };
}
export default useInputs;
JavaScript
복사
◦
src 폴더 > App.js
import logo from './logo.svg';
import './App.css';
import Home from './components/pages/Home';
import { BrowserRouter } from 'react-router-dom';
import Layout from './components/layouts/Layout';
import { Route } from 'react-router-dom';
import { Routes } from 'react-router-dom';
import { Navigate, Outlet } from "react-router-dom";
import Login from './components/pages/Login';
import SignUp from './components/pages/SignUp';
import TimeLine from './components/pages/TimeLine';
import Search from './components/pages/Search';
import Profile from './components/pages/Profile';
import MyProfile from './components/pages/MyProfile';
import NotFound from './components/pages/NotFound';
function App() {
return (
<Layout>
{/*
[누구나]
홈
타임라인 /post [게시물 리스트]
검색 /search [게시물 검색]
프로필 /profile/:id [특정 사람의 프로필]
404NOT FOUND /*
[회원만 - ]
마이프로필 /profile [내 프로필]
[게스트만]
회원가입 /signup
로그인 /login
*/
}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/post" element={<TimeLine />} />
<Route path="/search" element={<Search />} />
{/* 게스트만 접근 가능*/}
<Route element={<GuestRoute />}>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<SignUp />} />
</Route>
{/* 로그인한 회원만 접근 가능 */}
<Route element={<UserRoute />}>
<Route path="/profile" element={<MyProfile />} />
</Route>
<Route path="/profile:id" element={<Profile />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>
);
}
const GuestRoute = () => {
const loginUser = localStorage.getItem("loginUser");
const isLogin = !!loginUser;
return (
isLogin ? <Navigate to="/info" /> : <Outlet />
)
}
const UserRoute = () => {
const loginUser = localStorage.getItem("loginUser");
// loginUser = undefined || null
// !loginUser >>> true >> false
// const isLogin = loginUser ? true : false;
const isLogin = !!loginUser;
return (
isLogin ? <Outlet /> : <Navigate to="/login" />
)
}
export default App;
JavaScript
복사