React

 

 

백엔드 노드 서버 구축하기

 

버전이 다르기 때문에 소스가 강좌와 다를 수 있다.

버전

next:  13.0.4

antd:  5.0.1

 

소스 : https://github.dev/braverokmc79/node-bird-sns

 

제로초 소스 : https://github.com/ZeroCho/react-nodebird

 

 

 

 

 

61.  리트윗하기

강의 :

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC/unit/48849?tab=curriculum

 

백엔드

 

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 방식

강의 :

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC/unit/48850?tab=curriculum

 

 

백엔드

 

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);

 

 

 

 

 

 

 

 

 

react

 

about author

PHRASE

Level 60  라이트

잘 싸우는 자, 선전(善戰)하는 자가 승리한다는 말이 있다. 이것은 이길 수 있는 필승의 가망이 있는 상대와 싸워서 이기는 것을 말한 것이다. 이것이 참으로 이기는 것이고 운을 하늘에 맡기고 싸워서 이기는 것은 참으로 이긴 것이 아니다. 이길 가망이 없는 전쟁을 하는 자는 반드시 실패로 끝난다. -손자

댓글 ( 4)

댓글 남기기

작성