Backend
home

2024. 7. 5

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
복사

기타