백엔드 노드 서버 구축하기
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
next: 13.0.4
antd: 5.0.1
소스 : https://github.dev/braverokmc79/node-bird-sns
제로초 소스 : https://github.com/ZeroCho/react-nodebird
61. 리트윗하기
강의 :
백엔드
routes/post.js
~ //POST 리트윗 /post router.post('/:postId/retweet', isLoggedIn, async (req, res, next) => { //POST /post/1/retweet try { const post = await Post.findOne({ where: { id: req.params.postId }, include: { model: Post, as: 'Retweet' } }); if (!post) { return res.status(403).send("존재하지 않는 게시글입니다."); } //자기 게시글은 리트윗 할수 없다. 또는 다른사람이 리트윗한 게시물의 작성자 아이디가 , 자기 게시물이라면 리트윗금지 if (req.user.id === post.UserId || (post.Retweet && post.Retweet.UserId === req.user.id)) { return res.status(403).send("자신의 글은 리트윗 할수 없습니다."); } // 리트윗한 아이디가 존재하면 해당 리트윗 아이디를 사용하고, 없으면 게시글 아이디를 리트윗아이디로 한다. const retweetTargetId = post.RetweetId || post.id; const exPost = await Post.findOne({ where: { UserId: req.user.id, RetweetId: retweetTargetId } }); if (exPost) { return res.status(403).send("이미 리트윗했습니다."); } const retweet = await Post.create({ UserId: req.user.id, RetweetId: retweetTargetId, content: 'retweet' }); const retweetWithPrevPost = await Post.findOne({ where: { id: retweet.id }, include: [{ model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] }, { model: User, attributes: ['id', 'nickname'] }, { model: Image }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'] }] }, { model: User, as: 'Likers', attributes: ['id'] } ] }) res.status(201).json(retweetWithPrevPost); } catch (error) { console.error(" comment 에러 : ", error); next(error); } }); module.exports = router;
작성후 반환 값 및 목록 리스트 에
리트윗 데이터 추가
{ model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] }
routes/posts.js
const express = require('express'); const { Post, Image, User, Comment } = require('../models'); const router = express.Router(); //GET /posts router.get('/:limit', async (req, res, next) => { try { console.log("req.params : ", req.params.limit); const posts = await Post.findAll({ // where: { id: lastId }, limit: 10, order: [ ['createdAt', 'DESC'], [Comment, 'createdAt', 'DESC'] ], offsset: parseInt(req.params.limit), //21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1 include: [{ model: User, attributes: ['id', 'nickname'] }, { model: Image }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'] }] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] }, { model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] } ] }); res.status(200).json(posts); } catch (error) { console.error("posts error : ", error); next(error); } }); module.exports = router;
프론트엔드
reducer/post.js
import shortid from 'shortid'; import produce from 'immer'; import { faker } from '@faker-js/faker'; faker.seed(123); export const initialState = { ~ reTweetLoading: false, reTweetDone: false, reTweetError: null } export const RETWEET_REQUEST = 'RETWEET_REQUEST'; export const RETWEET_SUCCESS = 'RETWEET_SUCCESS'; export const RETWEET_FAILURE = 'RETWEET_FAILURE'; ~ //이전 상태를 액션을 통해 다음 상태로 만들어내는 함수(불변성은 지키면서) const reducer = (state = initialState, action) => produce(state, (draft) => { switch (action.type) { //이미지 업로드 case RETWEET_REQUEST: draft.reTweetLoading = true; draft.reTweetDone = false; draft.reTweetError = null; break; case RETWEET_SUCCESS: { draft.mainPosts.unshift(action.data); draft.reTweetLoading = false; draft.reTweetDone = true; break; } case RETWEET_FAILURE: draft.reTweetLoading = false; draft.reTweetError = action.error; break; ~
saga/post.js
import { all, fork, put, throttle, delay, takeLatest, call } from 'redux-saga/effects'; import axios from 'axios'; import { ADD_POST_REQUEST, ADD_POST_SUCCESS, ADD_POST_FAILURE, ADD_COMMENT_REQUEST, ADD_COMMENT_SUCCESS, ADD_COMMENT_FAILURE, REMOVE_POST_REQUEST, REMOVE_POST_SUCCESS, REMOVE_POST_FAILURE, LOAD_POSTS_REQUEST, LOAD_POSTS_SUCCESS, LOAD_POSTS_FAILURE, LIKE_POST_REQUEST, LIKE_POST_SUCCESS, LIKE_POST_FAILURE, UNLIKE_POST_REQUEST, UNLIKE_POST_SUCCESS, UNLIKE_POST_FAILURE, UPLOAD_IMAGES_REQUEST, UPLOAD_IMAGES_SUCCESS, UPLOAD_IMAGES_FAILURE, RETWEET_REQUEST, RETWEET_SUCCESS, RETWEET_FAILURE } from '../reducers/post' import { REMOVE_POST_OF_ME } from '../reducers/user'; //리트윗 function retweetAPI(data) { return axios.post(`/post/${data}/retweet`, data); } function* retweet(action) { try { const result = yield call(retweetAPI, action.data); yield put({ type: RETWEET_SUCCESS, data: result.data }); } catch (err) { console.log(err); yield put({ type: RETWEET_FAILURE, error: err.response.data }); } } function* watchRetweet() { yield takeLatest(RETWEET_REQUEST, retweet); } ~ export default function* postSaga() { yield all([ fork(watchUploadImages), fork(watchAddPost), fork(watchAddComment), fork(watchRemovePost), fork(watchLoadPosts), fork(watchLikePost), fork(watchUnlikePost), fork(watchRetweet) ]); }
front/page.js
import React, { useEffect, useCallback } from 'react'; import AppLayout from './../components/AppLayout'; import { useSelector, useDispatch } from 'react-redux'; import PostCard from './../components/PostCard'; import PostForm from './../components/PostForm'; import { LOAD_MY_INFO_REQUEST } from './../reducers/user'; import { LOAD_POSTS_REQUEST } from './../reducers/post'; const Index = () => { const dispatch = useDispatch(); const { me } = useSelector((state) => state.user); const { mainPosts, hasMorePosts, loadPostsLoading, reTweetError, reTweetDone } = useSelector((state) => state.post); useEffect(() => { dispatch({ type: LOAD_MY_INFO_REQUEST }); dispatch({ type: LOAD_POSTS_REQUEST }); }, []); useEffect(() => { if (reTweetError) { return alert(reTweetError); } }, [reTweetError]); useEffect(() => { if (reTweetDone) { alert("리트윗 되었습니다."); } }, [reTweetDone]); ~
components/PostCard.js
import React, { useState, useCallback } from 'react'; import { Card, Button, Avatar, Image, Popover, List, Space } from 'antd'; import { Comment } from '@ant-design/compatible'; import { RetweetOutlined, HeartOutlined, MessageOutlined, EllipsisOutlined, HeartTwoTone } from '@ant-design/icons'; import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux'; import PostImages from './PostImages'; import CommentForm from './CommentForm'; import { createGlobalStyle } from 'styled-components'; import PostCardContent from './PostCardContent'; import { REMOVE_POST_REQUEST } from '../reducers/post'; import FollowButton from './FollowButton'; import { LIKE_POST_REQUEST, UNLIKE_POST_REQUEST, RETWEET_REQUEST } from '../reducers/post'; const Global = createGlobalStyle` .ant-card-actions{ background: #eeeeee !important; } .ant-card-head-title{ background: #f0f0f0; padding: 16px !important; } `; const PostCard = ({ post }) => { const dispatch = useDispatch(); const { removePostLoading, reTweetDone } = useSelector((state) => state.post); const [commentFormOpened, setCommentFormOpened] = useState(false); const id = useSelector((state) => state.user.me?.id); const liked = post.Likers.find((v) => v.id === id); const onLike = useCallback(() => { if (!id) return alert("로그인이 필요합니다."); dispatch({ type: LIKE_POST_REQUEST, data: post.id }) }, []); const onUnlike = useCallback(() => { if (!id) return alert("로그인이 필요합니다."); dispatch({ type: UNLIKE_POST_REQUEST, data: post.id }) }, []); const onToggleComment = useCallback(() => { setCommentFormOpened((prev) => !prev); }, []); const onRemovePost = useCallback(() => { if (window.confirm("정말 삭제 하시겠습니까?")) { dispatch({ type: REMOVE_POST_REQUEST, data: post.id }) } }, []); const onRetweet = useCallback(() => { if (!id) return alert("로그인이 필요합니다."); dispatch({ type: RETWEET_REQUEST, data: post.id }); }, [id, reTweetDone]); const content = ( <div> {id && post.User.id === id ? ( <Space wrap> <Button type='primary' info>수정</Button> <Button type='primary' danger loading={removePostLoading} onClick={onRemovePost}>삭제</Button> </Space> ) : ( <Button>신고</Button> ) } </div > ); return ( <div style={{ marginBottom: 50 }}> <Global /> <Card key={post.Image} cover={post.Images[0] && <PostImages images={post.Images} />} actions={[ <RetweetOutlined key="retweet" onClick={onRetweet} />, liked ? <HeartTwoTone key="heart" twoToneColor="red" onClick={onUnlike} /> : <HeartOutlined key="heart" onClick={onLike} />, <MessageOutlined key="comment" onClick={onToggleComment} />, <Popover content={content} title="" key="popover" style={{ textAlign: "center" }} > <EllipsisOutlined /> </Popover> ]} title={post.Retweet && `'${post.User.nickname}'님이 리트윗 하셨습니다.`} extra={id && <FollowButton post={post} />} > {post.RetweetId && post.Retweet ? ( <Card cover={post.Retweet.Images[0] && <PostImages images={post.Retweet.Images} />} style={{ background: '#eee' }} > <Card.Meta avatar={<Avatar>{post.Retweet.User.nickname[0]}</Avatar>} title={post.Retweet.User.nickname} description={<PostCardContent postData={post.Retweet.content} />} /> </Card> ) : <Card.Meta avatar={<Avatar>{post.User.nickname[0]}</Avatar>} title={post.User.nickname} description={<PostCardContent postData={post.content} />} /> } <Image /> </Card > {commentFormOpened && ( <div> <CommentForm post={post} /> <List header={`${post.Comments.length} 개의 댓글`} itemLayout="horizontal" dataSource={post.Comments} renderItem={(item) => ( <li> <Comment author={item.User.nickname} avatar={<Avatar>{item.User.nickname[0]}</Avatar>} content={item.content} /> </li> )} /> </div> )} {/* <CommentForm /> <Comments /> */} </div > ); }; PostCard.propTypes = { post: PropTypes.shape({ id: PropTypes.number, User: PropTypes.object, content: PropTypes.string, createdAt: PropTypes.string, Comment: PropTypes.arrayOf(PropTypes.object), Images: PropTypes.arrayOf(PropTypes.object), Likers: PropTypes.arrayOf(PropTypes.object), RetweetId: PropTypes.number, Retweet: PropTypes.objectOf(PropTypes.any) }).isRequired } export default PostCard;
62. 쿼리스트링과 lastId 방식
강의 :
백엔드
routes/posts.js
const express = require('express'); const { Op } = require('sequelize'); const { Post, Image, User, Comment } = require('../models'); const router = express.Router(); //GET /posts router.get('/', async (req, res, next) => { try { console.log(" 마지막 아이디 :", req.query.lastId); const where = {}; if (parseInt(req.query.lastId, 10)) { //초기 로딩이 아닐때 //다음 코드 내용은 id 가 lastId 보다 작은 것 => id < lastId // Op 의미는 연산자 의미 lt 는 < where.id = { [Op.lt]: parseInt(req.query.lastId, 10) } }; const posts = await Post.findAll({ where, limit: 10, order: [ ['createdAt', 'DESC'], [Comment, 'createdAt', 'DESC'] ], // offsset: parseInt(req.params.limit), //21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1 include: [{ model: User, attributes: ['id', 'nickname'] }, { model: Image }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'] }] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] }, { model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] } ] }); res.status(200).json(posts); } catch (error) { console.error("posts error : ", error); next(error); } }); module.exports = router;
프론트엔드
page/index.js
`~ const Index = () => { const dispatch = useDispatch(); const { me } = useSelector((state) => state.user); const { mainPosts, hasMorePosts, loadPostsLoading, reTweetError, reTweetDone } = useSelector((state) => state.post); useEffect(() => { dispatch({ type: LOAD_MY_INFO_REQUEST }); const lastId = mainPosts[mainPosts.length - 1]?.id; dispatch({ type: LOAD_POSTS_REQUEST, lastId }); }, []); ~
saga/post.js
//무한 스크롤 function loadPostsAPI(lastId) { return axios.get(`/posts?lastId=${lastId || 0}&limit=10&offset=10`); } function* loadPosts(action) { try { // console.log("리액트 마지막 아이디 : ", action.lastId); const result = yield call(loadPostsAPI, action.lastId); // console.log(" 3무한 스크롤 generateDummyPost: ", generateDummyPost(10)); yield put({ type: LOAD_POSTS_SUCCESS, data: result.data }); } catch (err) { console.log(err); yield put({ type: LOAD_POSTS_FAILURE, error: err.response.data }); } } function* watchLoadPosts() { // yield takeLatest(LOAD_POSTS_REQUEST, loadPosts); yield throttle(3000, LOAD_POSTS_REQUEST, loadPosts); }
글작성 및 글 삭제 후 에러 사항이 있어서 등록 및 삭제 설정을 하였다.
setTimeout(() => { location.reload(); }, 300);
댓글 ( 4)
댓글 남기기