Mern-Youtube-Clone

Min·2021년 1월 4일
0

Project

목록 보기
3/6
post-thumbnail

Mern-Youtube-Clone

📝 프로젝트 설명

  • John Ahn님의 유튜브 사이트 만들기 강의를 수강하며 메인 페이지, 상세 비디오 페이지, 상세 페이지(구독, 댓글, 좋아요/싫어요 기능), 이미지 업로드 페이지를 학습한 내용을 정리한 프로젝트입니다.

💡 기술스택:

  • 프론트엔드 : React, Redux, Ant Design
  • 백엔드: Express, MongoDB

💡 사용 라이브러리

  • react-dropzone, multer, fluent-ffmpeg

0. 초기 설정

  • boiler-plate 코드

  • 클라이언트서버Dependencies 다운받기

    • npm install
    • ServerRoot 경로, Clientclient폴더 경로
  • server/config/dev.js 파일 설정

    • MongoDB 로그인
    • 클러스터, 유저 아이디와 비밀번호 생성 후 dev.js 파일에 넣는다.
⭐ server/config/dev.js
module.exports = {
  mongoURI:
    'mongodb+srv://devPark:<password>@react-boiler-plate.ovbtd.mongodb.net/react-youtube-clone?retryWrites=true&w=majority'
}

1. 비디오업로드 페이지

Video Model

MongoDBRDBMS
DatabaseDatabase
CollectionsTables
DocumentsRows
FieldsColumns
⭐ server/models/Video.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema

const videoSchema = mongoose.Schema({
    writer: {
        type: Schema.Types.ObjectId,
        ref: 'User'
    },
    title: {
        type: String,
        maxlength: 50,
    },
    description: {
        type: String,
    },
    privacy: {
        type: Number,
    },
    filePath: {
        type: String,
    },
    category: {
        type: String,
    },
    views: {
        type: Number,
        default: 0 
    },
    duration: {
        type: String
    },
    thumbnail: {
        type: String
    }
}, { timestamps: true })

const Video = mongoose.model('Video', videoSchema)

module.exports = { Video }

서버

multer,
ffmpeg

  • 썸네일 생성
    $ brew install ffmpeg 설치방법
    $ npm install fluent-ffmpeg --save
const express = require('express');
const router = express.Router();
const multer = require('multer');
var ffmpeg = require('fluent-ffmpeg');

const { Video } = require("../models/Video");
const { Subscriber } = require("../models/Subscriber");
const { auth } = require("../middleware/auth");

var storage = multer.diskStorage({
    destination: (req, file, cb) => {
        cb(null, 'uploads/')
    },
    filename: (req, file, cb) => {
        cb(null, `${Date.now()}_${file.originalname}`)
    },
    fileFilter: (req, file, cb) => {
        const ext = path.extname(file.originalname)
        if (ext !== '.mp4') {
            return cb(res.status(400).end('only jpg, png, mp4 is allowed'), false);
        }
        cb(null, true)
    }
})

var upload = multer({ storage: storage }).single("file")


//=================================
//             User
//=================================

⭐ server/routes/video.js

router.post('/uploadfiles', (req, res) => {

    upload(req, res, err => {
        if (err) {
            return res.json({ success: false, err })
        }
        return res.json({ success: true, filePath: res.req.file.path, fileName: res.req.file.filename })
    })

});

router.post('/thumbnail', (req, res) => {

    // 썸네일 생성하고 비디오 러닝타임도 가져오기

    let thumbsFilePath = ''
    let fileDuration = ''

    // 비디오 러닝타임 가져오기
    ffmpeg.ffprobe(req.body.filePath, function(err, metadata){
        console.dir(metadata) // all metadata
        console.log(metadata.format.duration)
        fileDuration = metadata.format.duration
    })

    // 썸네일 생성
    ffmpeg(req.body.filePath)
        // 썸네일의 filename을 생성
        .on('filenames', function (filenames) {
            console.log('Will generate ' + filenames.join(', '))
            console.log(filenames)
            thumbsFilePath = 'uploads/thumbnails/' + filenames[0]
        })
        // 썸네일 생성 후 처리
        .on('end', function () {
            console.log('Screenshots taken')
            return res.json({ success: true, thumbsFilePath: thumbsFilePath, fileDuration: fileDuration })
        })
        // 에러처리
        .on('error', function(err) {
            console.error(err)
            return res.json({ success: false, err })
        })
        // 옵션
        .screenshots({
            // Will take screens at 20%, 40%, 60% and 80% of the video
            count: 3,
            folder: 'uploads/thumbnails/',
            size:'320x240',
            // %b input basename ( filename w/o extension )
            filename:'thumbnail-%b.png'
        })

})

router.post('/uploadVideo', (req, res) => {

    // 비디오 정보를 mongoDB에 저장한다

    const video = new Video(req.body)

    video.save((err, doc) => {
        if(err) return res.status(400).json({ success: false, err })
        return res.status(200).json({
            success: true 
        })
    })

})

클라이언트

  • dropzone
⭐ VideoUploadPage.js

import React, { useState } from 'react'
import { Typography, Form, Input, Button, Icon} from 'antd'
import Dropzone from 'react-dropzone'
import axios from 'axios'
import { useSelector } from 'react-redux'

const { Title } = Typography
const { TextArea } = Input

const PrivateOptions = [
    { value: 0, label: 'Private' },
    { value: 1, label: 'Public' }
]

const CatogoryOptions = [
    { value: 0, label: 'Film & Animation' },
    { value: 1, label: 'Autos & Vehicles' },
    { value: 2, label: 'Music' },
    { value: 3, label: 'Pets & Animals' },
    { value: 4, label: 'Sports' }
]

function VideoUploadPage(props) {

    // 리덕스의 state 스토어에 가서 user 정보를 선택
    const user = useSelector(state => state.user)
    const [VideoTitle, setVideoTitle] = useState('')
    const [Description, setDescription] = useState('')
    const [Privacy, setPrivacy] = useState(0)
    const [Category, setCategory] = useState('Film & Animation')
    // 비디오썸네일 저장 데이터
    const [FilePath, setFilePath] = useState('')
    const [Duration, setDuration] = useState('')
    const [ThumbnailPath, setThumbnailPath] = useState('')

    const onTitleChange = (e) => {
        setVideoTitle(e.currentTarget.value)
    }

    const onDescriptionChange = (e) => {
        setDescription(e.currentTarget.value)
    }

    const onPrivateChange = (e) => {
        setPrivacy(e.currentTarget.value)
    }

    const onCategoryChange = (e) => {
        setCategory(e.currentTarget.value)
    }

    const onDrop = (files) => {
        let formData = new FormData();
        const config = {
            header: { 'content-type': 'multipart/form-data' }
        }

        formData.append("file", files[0])

        // 비디오 업로드
        axios.post('/api/video/uploadfiles', formData, config)
        .then(response => {
            if (response.data.success) {

                let variable = {
                    filePath: response.data.filePath,
                    fileName: response.data.fileName
                }

                setFilePath(response.data.filePath)

                // filepath를 이용해 썸네일 만들기 
                axios.post('/api/video/thumbnail', variable)
                    .then(response => {
                        if (response.data.success) {
                            setDuration(response.data.fileDuration)
                            setThumbnailPath(response.data.thumbsFilePath)
                        } else {
                            alert('썸네일 생성에 실패했습니다.');
                        }
                    })         

            } else {
                alert('비디오 업로드를 실패했습니다.')
            }
        })
    }

    const onSubmit = (e) => {

        e.preventDefault()

        // Video Collention에 모두 넣기 위해
        const variables = {
            // 리덕스의 State에서 확인 가능 (user.userData)
            // react-redux의 useSelector를 이용
            writer: user.userData._id,
            title: VideoTitle,
            description: Description,
            privacy: Privacy,
            filePath: FilePath,
            category: Category,
            duration: Duration,
            thumbnail: ThumbnailPath
        }

        axios.post('/api/video/uploadVideo', variables)
            .then(response => {
                if (response.data.success) {
                    alert('성공적으로 업로드를 했습니다.')
                    setTimeout(() => {
                        props.history.push('/')
                    }, 3000)
                } else {
                    alert('비디오 업로드에 실패했습니다.')
                }
            })

    }

    return (
        <div style={{ maxWidth: '700px', margin: '2rem auto' }}>
            <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
                <Title level={2} > Upload Video</Title>
            </div>

            <Form onSubmit={onSubmit}>
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                    
                    {/* Drop Zone */}
                    <Dropzone
                        onDrop={onDrop}
                        multiple={false}
                        maxSize={800000000}>
                        {({ getRootProps, getInputProps }) => (
                            <div style={{ width: '300px', height: '240px', border: '1px solid lightgray', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
                                {...getRootProps()}
                            >
                                <input {...getInputProps()} />
                                <Icon type="plus" style={{ fontSize: '3rem' }} />

                            </div>
                        )}
                    </Dropzone>

                    {/* Thumbnail */}
                    {ThumbnailPath &&
                        <div>
                            <img src={`http://localhost:5000/${ThumbnailPath}`} alt="thumbnail" />
                        </div>
                    }
                    
                </div>
        
                <br />
                <br />

                <label>Title</label>
                <Input 
                    onChange={onTitleChange}
                    value={VideoTitle}
                />

                <br />
                <br />

                <label>Description</label>
                <TextArea
                    onChange={onDescriptionChange}
                    value={Description}
                />

                <br />
                <br />

                <select onChange={onPrivateChange}>
                    {PrivateOptions.map((item, index) => (
                        <option key={index} value={item.value}>{item.label}</option>
                    ))}
                </select>

                <br />
                <br />

                <select onChange={onCategoryChange}>
                    {CatogoryOptions.map((item, index) => (
                        <option key={index} value={item.value}>{item.label}</option>
                    ))}
                </select>

                <br />
                <br />

                <Button type="primary" size="large" onClick={onSubmit}>
                    Submit
                </Button>

            </Form>

        </div>
    )
}

export default VideoUploadPage

2. 랜딩 페이지

1) 서버

⭐ server/routes/video.js

router.get('/getVideos', (req, res) => {

    Video.find()
        /* server/models/Video.js
        writer: {
        type: Schema.Types.ObjectId,
        ref: 'User'
        } */
        .populate('writer')
        .exec((err, videos) => {
            if(err) return res.status(400).send(err)
            return res.status(200).json({ success: true, videos })
        })

})

2) 클라이언트

⭐ LandingPage.js

import React, { useEffect, useState } from 'react'
import axios from 'axios'
import moment from 'moment'
import { Card, Avatar, Col, Typography, Row } from 'antd'

const { Title } = Typography
const { Meta } = Card

function LandingPage() {

    const [Video, setVideo] = useState([])

    useEffect(() => {
        axios.get('/api/video/getVideos')
            .then(response => {
                if (response.data.success) {
                    setVideo(response.data.videos)
                } else {
                    alert('비디오를 가져오는데 실패했습니다.')
                }
            })
    }, [])

    const renderCards = Video.map((video, index) => {

        var minutes = Math.floor(video.duration / 60);
        var seconds = Math.floor(video.duration - minutes * 60);

        return <Col key={index} lg={6} md={8} xs={24}>
            <a href={`/video/${video._id}`} >
                <div style={{ position: 'relative' }}>
                    <img style={{ width: '100%' }} src={`http://localhost:5000/${video.thumbnail}`} />
                    <div className="duration">
                      <span>{minutes} : {seconds}</span>
                    </div>
                </div>
            </a>
            <br />
            <Meta
                avatar={
                    <Avatar src={video.writer.image} />
                }
                title={video.title}
                description
            />
            <span>{video.writer.name} </span><br />
            <span style={{ marginLeft: '3rem' }}> {video.views} views</span>
            - <span> {moment(video.createdAt).format("MMM Do YY")} </span>
        </Col>

    })

    return (
        <div style={{ width: '85%', margin: '3rem auto' }}>
            <Title level={2} > Recommended </Title>
            <hr />

            <Row gutter={[32, 16]}>
                {renderCards}
            </Row>
        </div>
    )
}

export default LandingPage
⭐ client/src/index.css

// 비디오 시간에 관한 CSS
.duration {
  bottom: 0;
  right: 0;
  position: absolute;
  margin: 4px;
  color: #fff;
  background-color: rgba(17, 17, 17, 0.8);
  opacity: 0.8;
  padding: 2px 4px;
  border-radius: 2px;
  letter-spacing: 0.5px;
  font-size: 12px;
  font-weight: 500;
  line-height: 12px;
}

3. 비디오상세 페이지

1) 서버

⭐ server/routes/video.js

router.post('/getVideoDetail', (req, res) => {

    Video.findOne({ "_id" : req.body.videoId })
        .populate('writer')
        .exec((err, videoDetail) => {
            if(err) return res.status(400).send(err)
            return res.status(200).json({ success: true, videoDetail })
    })
})

2) 비디오상세 페이지(부모 컴포넌트)

// App.js
import VideoDetailPage from './views/VideoDetailPage/VideoDetailPage'

function App() {
  return (
    <Switch>
      <Route
        exact
        path="/video/:videoId"
        component={Auth(VideoDetailPage, null)}
      />
    </Switch>
  )
}
⭐ VideoDetailPage.js

import React, { useEffect, useState } from 'react'
import { List, Avatar, Row, Col } from 'antd'
import axios from 'axios'
import SideVideo from './Sections/SideVideo'
import Subscriber from './Sections/Subscriber'
import Comment from './Sections/Comment'
import LikeDislikes from './Sections/LikeDislikes'

function VideoDetailPage(props) {

    const videoId = props.match.params.videoId
    const variable = { videoId: videoId }

    const [VideoDetail, setVideoDetail] = useState([])
    const [Comments, setComments] = useState([])


    useEffect(() => {
        
        axios.post('/api/video/getVideoDetail', variable)
            .then(response => {
                if(response.data.success) {
                    setVideoDetail(response.data.videoDetail)
                } else {
                    alert('비디오 정보를 가져오길 실패했습니다.')
                }
            })

        axios.post('/api/comment/getComments', variable)
            .then(response => {
                if(response.data.success) {
                    setComments(response.data.comments)
                    console.log(response.data.comments)
                } else {
                    alert('코멘트 정보를 가져오길 실패했습니다.')
                }
            })


    }, [])

    const refreshFunction = (newComment) => {
        setComments(Comments.concat(newComment))
    }

    if(VideoDetail.writer) {

        const subscribeButton = VideoDetail.writer._id !== localStorage.getItem('userId')
                                && <Subscriber
                                userTo={VideoDetail.writer._id}
                                // userFrom : 개발자도구 - Application - Local Storage - userId
                                userFrom={localStorage.getItem('userId')} />

        return (
            <Row gutter={[16, 16]}>
                <Col lg={18} xs={24}>
                    <div style={{ width: '100%', padding: '3rem 4em' }}>
                        <video style={{ width: '100%' }}
                        src={`http://localhost:5000/${VideoDetail.filePath}`}
                        controls />

                        <List.Item
                            actions={[ <LikeDislikes vdieo 
                                userId={localStorage.getItem('userId')} 
                                videoId={videoId}/>, 
                                subscribeButton ]}
                        >
                        <List.Item.Meta
                            avatar={<Avatar src={VideoDetail.writer.image} />}
                            title={VideoDetail.writer.name}
                            description={VideoDetail.description}
                        />
                        </List.Item>
                        
                        {/* Comments */}
                        <Comment postId={videoId} refreshFunction={refreshFunction} commentLists={Comments}/>

                    </div>
                </Col>

                <Col lg={6} xs={24}>
                    <SideVideo />
                </Col>
            </Row>
        )

    } else {
        return (
            <div>...loading</div>
        )
    }
}

export default VideoDetailPage

3) 비디오상세 페이지(자식 컴포넌트)

⭐ VideoDetailPage/Sections/SideVideo.js

import React, {useEffect, useState} from 'react'
import axios from 'axios'

function SideVideo() {

    const [SideVideos, setSideVideos] = useState([])

    useEffect(() => {
        axios.get('/api/video/getVideos')
            .then(response => {
                if (response.data.success) {
                    setSideVideos(response.data.videos)
                } else {
                    alert('비디오를 가져오는데 실패했습니다.')
                }
            })
    }, [])

    const renderSideVideo = SideVideos.map(( video, index) => {

        var minutes = Math.floor(video.duration / 60)
        var seconds = Math.floor(video.duration - minutes * 60)

       return <div key={index} style={{ display: 'flex', marginTop: '1rem', padding: '0 2rem' }}>
        <div style={{ width:'40%', marginRight:'1rem' }}>
            <a href>
                <img
                style={{ width: '100%' }}
                src={`http://localhost:5000/${video.thumbnail}`}
                alt="thumbnail" />
            </a>
        </div>

        <div style={{ width:'50%' }}>
            <a href style={{ color:'gray' }}>
                <span style={{ fontSize: '1rem', color: 'black' }}>{video.title}  </span><br />
                <span> {video.writer.name} </span><br />
                <span> {video.views} views </span><br />
                <span> {minutes} : {seconds} </span><br />
            </a>
        </div>
    </div>
    })

    return (
        <React.Fragment>
            <div style={{ marginTop:'3rem' }}></div>

            {renderSideVideo}

        </React.Fragment>
    )
}

export default SideVideo

4. 구독(취소) 기능

1) Subscriber Model

  • userTo, userFrom
// server/models/Subscriber.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema

const subscriberSchema = mongoose.Schema({
    userTo: {
        type: Schema.Types.ObjectId,
        ref: 'User'
    },
    userFrom : {
        type: Schema.Types.ObjectId,
        ref: 'User'
    }

}, { timestamps: true })

const Subscriber = mongoose.model('Subscriber', subscriberSchema)

module.exports = { Subscriber }

2) 서버-video

⭐ server/routes/video.js
const { Subscriber } = require('../models/Subscriber')
router.post('/getSubscriptionVideos', (req, res) => {

    // 1. 자신의 아이디를 가지고 Subscriber Collectionp에서 구독한 사람들을 찾는다.
    Subscriber.find({ 'userFrom': req.body.userFrom })
    .exec(( err, subscriberInfo )=> {
        if(err) return res.status(400).send(err)

        // userTo의 정보를 모두 넣어준다.
        let subscribedUser = []

        subscriberInfo.map((subscriber, i)=> {
            subscribedUser.push(subscriber.userTo)
        })

        // 2. 찾은 사람들의 비디오를 가지고 온다.
        // 정보가 2개 이상일 때는 { writer : req.body.id } 가 사용되지 못한다.
        Video.find({ writer: { $in: subscribedUser }})
            .populate('writer')
            .exec((err, videos) => {
                if(err) return res.status(400).send(err)
                return res.status(200).json({ success: true, videos })
            })
    })
})

3) 서버-subscription

⭐ server/routes/subscribe.js

const express = require('express');
const router = express.Router();


const { Subscriber } = require('../models/Subscriber')

const { auth } = require("../middleware/auth")

//=================================
//             Subscribe
//=================================


router.post('/subscribeNumber', (req, res) => {

    Subscriber.find({ 'userTo': req.body.userTo })
    .exec((err, subscribe) => {
        if(err) return res.status(400).send(err)
        return res.status(200).json({ success: true, subscribeNumber: subscribe.length  })
    })

})

router.post('/subscribed', (req, res) => {

    Subscriber.find({ 'userTo': req.body.userTo , 'userFrom': req.body.userFrom })
    .exec((err, subscribe) => {
        if(err) return res.status(400).send(err)

        let result = false;
        if(subscribe.length !== 0) {
            result = true
        }

        return res.status(200).json({ success: true, subcribed: result })
    })

})

router.post('/unSubscribe', (req, res) => {

    Subscriber.findOneAndDelete({ userTo: req.body.userTo, userFrom: req.body.userFrom })
        .exec((err, doc)=>{
            if(err) return res.status(400).json({ success: false, err })
            return res.status(200).json({ success: true, doc })
        })
})

router.post('/subscribe', (req, res) => {

    const subscribe = new Subscriber(req.body)

    subscribe.save((err, doc) => {
        if(err) return res.json({ success: false, err })
        return res.status(200).json({ success: true })
    })

})

module.exports = router

클라이언트

⭐ SubscriptionPage.js

import React, { useEffect, useState } from 'react'
import { Card, Avatar, Col, Typography, Row } from 'antd'
import axios from 'axios'
import moment from 'moment'

const { Title } = Typography
const { Meta } = Card

function SubscriptionPage() {

    const [Videos, setVideos] = useState([])

    useEffect(() => {

        const subscriptionVariables = {
            userFrom: localStorage.getItem('userId')
        }

        axios.post('/api/video/getSubscriptionVideos', subscriptionVariables)
            .then(response => {
                if (response.data.success) {
                    setVideos(response.data.videos)
                } else {
                    alert('구독한 비디오를 가져오는데 실패했습니다.')
                }
            })
    }, [])

    const renderCards = Videos.map((video, index) => {

        var minutes = Math.floor(video.duration / 60);
        var seconds = Math.floor(video.duration - minutes * 60);

        return <Col key={index} lg={6} md={8} xs={24}>
            <a href={`/video/${video._id}`} >
                <div style={{ position: 'relative' }}>
                    <img style={{ width: '100%' }} src={`http://localhost:5000/${video.thumbnail}`} />
                    <div className="duration">
                      <span>{minutes} : {seconds}</span>
                    </div>
                </div>
            </a>
            <br />
            <Meta
                avatar={
                    <Avatar src={video.writer.image} />
                }
                title={video.title}
                description
            />
            <span>{video.writer.name} </span><br />
            <span style={{ marginLeft: '3rem' }}> {video.views} views</span>
            - <span> {moment(video.createdAt).format("MMM Do YY")} </span>
        </Col>

    })

    return (
        <div style={{ width: '85%', margin: '3rem auto' }}>
            <Title level={2} > Recommended </Title>
            <hr />

            <Row gutter={[32, 16]}>
                {renderCards}
            </Row>
        </div>
    )
}

export default SubscriptionPage
⭐ VideoDetailPage/Sections/Subscriber.js

import React, { useEffect, useState } from 'react'
import axios from 'axios'

function Subscriber(props) {

    const [SubscribeNumber, setSubscribeNumber] = useState(0)
    const [Subscribed, setSubscribed] = useState(false)

    useEffect(() => {

        // userTo : VideoDetailPage.js의 userTo={VideoDetail.writer._id}
        const variable = { userTo: props.userTo }

        axios.post('/api/subscribe/subscribeNumber', variable)
            .then(response => {
                if (response.data.success) {
                    setSubscribeNumber(response.data.subscribeNumber)
                } else {
                    alert('구독자 수 정보를 가져오지 못했습니다.')
                }
            })

        let subscribedVariable = { userTo: props.userTo, userFrom: props.userFrom }
        // 잘못된 코드 : let subscribedVariable = { userTo: props.userTo, userFrom: localStorage.getItem('userId')}
        // 다른 방법 : 
        // const userTo = props.userTo
        // const userFrom = props.userFrom
        // let subscribedVariable = { userTo: userTo, userFrom: userFrom }

        axios.post('/api/subscribe/subscribed', subscribedVariable)
        .then(response => {
            if (response.data.success) {
                setSubscribed(response.data.subcribed)
            } else {
                alert('구독 정보를 가져오지 못했습니다.')
            }
        })

    }, [])

    const onSubscribe = ( ) => {

        let subscribeVariables = {
                userTo : props.userTo,
                userFrom : props.userFrom
        }

        // 이미 구독 중이라면
        if(Subscribed) {

            axios.post('/api/subscribe/unSubscribe', subscribeVariables)
                .then(response => {
                    if(response.data.success){ 
                        setSubscribeNumber(SubscribeNumber - 1)
                        setSubscribed(!Subscribed)
                    } else {
                        alert('구독을 취소하는데 실패했습니다.')
                    }
                })

        // 구독중이지 않다면
        } else {
            
            axios.post('/api/subscribe/subscribe', subscribeVariables)
                .then(response => {
                    if(response.data.success) {
                        setSubscribeNumber(SubscribeNumber + 1)
                        setSubscribed(!Subscribed)
                    } else {
                        alert('구독하는데 실패했습니다.')
                    }
                })
        }

    }

    return (
        <div>
            <button 
                style={{
                    backgroundColor: `${Subscribed ? '#AAAAAA' : '#CC0000'}`, borderRadius: '4px',
                    color: 'white', padding: '10px 16px', fontWeight: '500',
                    fontSize: '1rem', textTransform: 'uppercase'
                }}
                onClick={onSubscribe}
            >
                {SubscribeNumber} {Subscribed ? 'Subscribed' : 'Subscribe'}
            </button>
        </div>
    )
}

export default Subscriber

5. 댓글 기능

1) Comment Model

writer, postId, responseTo, content

⭐ server/models/Comment.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema

const commentSchema = mongoose.Schema({
    writer: {
        type: Schema.Types.ObjectId,
        ref: 'User'
    },
    postId: {
        type: Schema.Types.ObjectId,
        ref: 'Video'
    },
    responseTo: {
        type: Schema.Types.ObjectId,
        ref: 'User'
    },
    content: {
        type: String
    }

}, { timestamps: true })

const Comment = mongoose.model('Comment', commentSchema)

module.exports = { Comment }

2) Comment

  • 저장된 댓글 데이터를 Parent Component(VideoDetailPage.js)로 업데이트 하기
  • 콘솔창에서 댓글 리스트들 확인
    • SingleComment.js : props.refreshFunction
    • Comment.js : props.refreshFunction
    • VideoDetailPage.js : loadComments
⭐ server/routes/comment.js
const express = require('express')
const router = express.Router()

const { Comment } = require('../models/Comment')

//=================================
//             Subscribe
//=================================

router.post('/saveComment', (req, res) => {

    const comment = new Comment(req.body)

    comment.save((err, comment) => {
        if (err) return res.json({ success: false, err })

        Comment.find({ '_id': comment._id })
            .populate('writer')
            .exec((err, result) => {
                if (err) return res.json({ success: false, err })
                return res.status(200).json({ success: true, result })
            })
    })

})

module.exports = router
// VideoDetailPage/Sections/Comment.js
import React, { useState } from 'react'
import axios from 'axios'
import { useSelector } from 'react-redux'

function Comment(props) {

    const videoId = props.postId
    // useSelector를 사용하여 writer state를 리덕스에서 가져오기
    const user = useSelector(state => state.user)

    const [commentValue, setcommentValue] = useState('')

    const handleClick = (event) => {
        setcommentValue(event.currentTarget.value)
    }

    const onSubmit = (event) => {
        event.preventDefault()

        const variables = {
            content: commentValue,
            // useSelector를 사용하여 writer state를 리덕스에서 가져오기
            writer: user.userData._id,
            postId: props.videoId
        }

        axios.post('/api/comment/saveComment', variables)
            .then(response => {
                if (response.data.success) {
                    props.refreshFunction(response.data.result)
                } else {
                    alert('코멘트를 저장하지 못했습니다.')
                }
            })

    }

    return (
        <div>
            <br />
            <p> Replies </p>
            <hr />

            {/* Root Comment Form */}
            <form style={{ display: 'flex' }} onSubmit={onSubmit}>
                <textarea
                    style={{ width: '100%', borderRadius: '5px' }}
                    onChange={handleClick}
                    value={commentValue}
                    placeholder="코멘트를 작성해 주세요"
                />
                <br />
                <button style={{ width: '20%', height: '52px' }}
                onClick={onSubmit}>Submit</button>
            </form>
        </div>
    )
}

export default Comment

2) SingleComment

저장된 댓글을 부모컴포넌트에 업데이트

// VideoDetailPage/Sections/Comment.js
function Comment(props) {
  ...
    return (
        {/* Comment Lists */}
        {props.commentLists && props.commentLists.map((comment, index) => (
            (!comment.responseTo &&
                <SingleComment
                postId={videoId}
                refreshFunction={props.refreshFunction}
                comment={comment} />
            )
        ))}
    )
}// VideoDetailPage/Sections/SingleComment.js
import React, { useState } from 'react'
import { Comment, Avatar, Button, Input } from 'antd'
import axios from 'axios'
import { useSelector } from 'react-redux'

const { TextArea } = Input

function SingleComment(props) {

    // useSelector를 사용하여 writer state를 리덕스에서 가져오기
    const user = useSelector(state => state.user)

    const [OpenReply, setOpenReply] = useState(false)
    const [CommentValue, setCommentValue] = useState('')

    const onClickReplyOpen = () => {
        setOpenReply(!OpenReply)
    }

    const onHandleChange = (event) => {
        setCommentValue(event.currentTarget.value)
    }

    const onSubmit = (event) => {
        event.preventDefault()

        const variables = {
            content: CommentValue,
            // useSelector를 사용하여 writer state를 리덕스에서 가져오기
            writer: user.userData._id,
            postId: props.postId,
            responseTo : props.comment._id
        }

        axios.post('/api/comment/saveComment', variables)
            .then(response => {
                if (response.data.success) {
                    props.refreshFunction(response.data.result)
                } else {
                    alert('코멘트를 저장하지 못했습니다.')
                }
            })

    }

    const actions = [
        <span onClick={onClickReplyOpen} key="comment-basic-reply-to"> Reply to </span>
    ]

    return (
        <div>
            <Comment
                actions={actions}
                author={props.comment.writer.name}
                avatar={<Avatar src={props.comment.writer.image} alt />}
                content={<p> {props.comment.content} </p>}
            />

            {OpenReply &&
                <form style={{ display: 'flex' }} onSubmit={onSubmit}>
                    <textarea
                        style={{ width: '100%', borderRadius: '5px' }}
                        onChange={onHandleChange}
                        value={CommentValue}
                        placeholder="코멘트를 작성해 주세요"
                    />
                    <br />
                    <button style={{ width: '20%', height: '52px' }}
                    onClick={onSubmit}>Submit</button>
                </form>
            }

        </div>
    )
}

export default SingleComment

⭐// server/routes/comment.js
router.post('/getComments', (req, res) => {

    Comment.find({ "postId": req.body.videoId })
        .populate('writer')
        .exec((err, comments) => {
            if (err) return res.status(400).send(err)
            return res.status(200).json({ success: true, comments })
        })

})

10-4. ReplyComment

  • 자식 코멘트 수 구하기
  • Complete Comment System
// VideoDetailPage/Sections/Comment.js
function Comment(props) {
  ...
    return (
        {/* Comment Lists */}
            {props.commentLists && props.commentLists.map((comment, index) => (
                (!comment.responseTo &&
                    <React.Fragment>
                        <SingleComment postId={videoId}
                        refreshFunction={props.refreshFunction}
                        comment={comment} />

                        <ReplyComment postId={videoId}
                        refreshFunction={props.refreshFunction}
                        parentCommentId={comment._id}
                        commentLists={props.commentLists}/>
                    </React.Fragment>
                )
            ))}
    )
}// VideoDetailPage/Sections/ReplyComment.js
import React, { useEffect, useState } from 'react'
import SingleComment from '../Sections/SingleComment'

function ReplyComment(props) {


    const [ChildCommentNumber, setChildCommentNumber] = useState(0)
    const [OpenReplyComments, setOpenReplyComments] = useState(false)

    useEffect(() => {

        let commentNumber = 0

        props.commentLists.map((comment) => {

            if(comment.responseTo === props.parentCommentId) {
                commentNumber ++
            }

        })

        setChildCommentNumber(ChildCommentNumber)

    }, [props.commentLists])

    const renderReplyComment = (parentCommentId) =>
        props.commentLists.map((comment, index) => (
            <React.Fragment>
                {
                    comment.responseTo === parentCommentId &&
                    <div style={{ width: '80%', marginLeft: '40px' }}>
                        <SingleComment postId={props.videoId}
                        refreshFunction={props.refreshFunction}
                        comment={comment} />

                        <ReplyComment postId={props.videoId}
                        refreshFunction={props.refreshFunction}
                        parentCommentId={comment._id}
                        commentLists={props.commentLists}/>
                    </div>
                }
            </React.Fragment>
        ))

        const onHandleChange = () => {
            setOpenReplyComments(!OpenReplyComments)
        }


    return (
        <div>

            {ChildCommentNumber > 0 &&
                <p style={{ fontSize: '14px', margin: 0, color: 'gray' }} onClick={onHandleChange}>
                    View 1 more comment(s)
                </p>
            }

            {OpenReplyComments &&
                renderReplyComment(props.parentCommentId)
            }

        </div>
    )
}

export default ReplyComment

⭐// VideoDetailPage/Sections/SingleComment.js
function SingleComment(props) {

  const onSubmit = (event) => {
        event.preventDefault()

        const variables = {
            content: CommentValue,
            // useSelector를 사용하여 writer state를 리덕스에서 가져오기
            writer: user.userData._id,
            postId: props.postId,
            responseTo : props.comment._id
        }

        axios.post('/api/comment/saveComment', variables)
            .then(response => {
                if (response.data.success) {
                    setCommentValue('')
                    setOpenReply(false)
                    props.refreshFunction(response.data.result)
                } else {
                    alert('코멘트를 저장하지 못했습니다.')
                }
            })

    }

7. 좋아요/싫어요 기능

1) Like, DisLike Model

// server/models/Like.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema

const likeSchema = mongoose.Schema(
  {
    userId: {
      type: Schema.Types.ObjectId,
      ref: 'User',
    },
    commentId: {
      type: Schema.Types.ObjectId,
      ref: 'Comment',
    },
    videoId: {
      type: Schema.Types.ObjectId,
      ref: 'Video',
    },
  },
  { timestamps: true }
)

const Like = mongoose.model('Like', likeSchema)

module.exports = { Like }

// server/models/DisLike.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema

const dislikeSchema = mongoose.Schema(
  {
    userId: {
      type: Schema.Types.ObjectId,
      ref: 'User',
    },
    commentId: {
      type: Schema.Types.ObjectId,
      ref: 'Comment',
    },
    videoId: {
      type: Schema.Types.ObjectId,
      ref: 'Video',
    },
  },
  { timestamps: true }
)

const Dislike = mongoose.model('Dislike', dislikeSchema)

module.exports = { Dislike }

2)

  • 현재 좋아요 싫어요에 대한 정보를 DB에서 가져오기
    • (Request 보낼때 변수는 Video에 것과 Comment에 것이 달라야 한다.)
    • 좋아요 싫어요 숫자
    • 내가 좋아요나 싫어요 둘중 하나를 이미 눌렀는지
// VideoDetailPage/Sections/SingleComment.js
import LikeDislikes from './LikeDislikes'

  function SingleComment(props) {
  const actions = [
        <LikeDislikes
        userId={localStorage.getItem('userId')}
        commentId={props.comment._id}/>
        ,<span onClick={onClickReplyOpen} key="comment-basic-reply-to"> Reply to </span>
    ]
  return (

  )
}

// VideoDetailPage/Sections/LikeDislike.js
import React, { useEffect, useState } from 'react'
import { Tooltip, Icon } from 'antd'
import axios from 'axios'

function LikeDislikes(props) {

    const [Likes, setLikes] = useState(0)
    const [Dislikes, setDislikes] = useState(0)
    const [LikeAction, setLikeAction] = useState(null)
    const [DislikeAction, setDislikeAction] = useState(null)

    let variable = {}

    if (props.video) {
        variable = { videoId: props.videoId, userId: props.userId }
    } else {
        variable = { commentId: props.commentId, userId: props.userId }
    }

    useEffect(() => {

        axios.post('/api/like/getLikes', variable)
            .then(response => {
                if (response.data.success) {

                    // 얼마나 많은 좋아요를 받았는지
                    setLikes(response.data.likes.length)

                    // 내가 이미 그 좋아요를 눌렀는지
                    response.data.likes.map(like => {
                        if (like.userId === props.userId) {
                            setLikeAction('liked')
                        }
                    })
                } else {
                    alert('Likes 정보를 가져오는데 실패했습니다.')
                }
            })

        axios.post('/api/like/getDislikes', variable)
            .then(response => {
                if (response.data.success) {

                    // 얼마나 많은 싫어요를 받았는지
                    setDislikes(response.data.dislikes.length)

                    // 내가 이미 그 싫어요를 눌렀는지
                    response.data.dislikes.map(dislike => {
                        if (dislike.userId === props.userId) {
                            setDislikeAction('disliked')
                        }
                    })
                } else {
                    alert('DisLikes 정보를 가져오는데 실패했습니다')
                }
            })

    }, [])

    const onLike = () => {

        // Like이 클릭이 되어있지 않았을 때
        if (LikeAction === null) {

            axios.post('/api/like/upLike', variable)
                .then(response => {
                    if (response.data.success) {

                        setLikes(Likes + 1)
                        setLikeAction('liked')

                        // 만약 dislike가 이미 클릭되어 있으면
                        if (DislikeAction !== null) {
                            setDislikeAction(null)
                            setDislikes(Dislikes - 1)
                        }

                    } else {
                        alert('Like를 올리지 못하였습니다.')
                    }
                })

        } else {

            // Like이 클릭이 되어있을 때
            axios.post('/api/like/unLike', variable)
                .then(response => {
                    if (response.data.success) {

                        setLikes(Likes - 1)
                        setLikeAction(null)

                    } else {
                        alert('Like를 내리지 못하였습니다.')
                    }
                })
        }

    }

    const onDisLike = () => {

        // Disklike가 클릭이 되어있을 때
        if (DislikeAction !== null) {

            axios.post('/api/like/unDisLike', variable)
                .then(response => {
                    if (response.data.success) {

                        setDislikes(Dislikes - 1)
                        setDislikeAction(null)

                    } else {
                        alert('Dislike를 내리는데 실패했습니다.')
                    }
                })

        // Dislike가 클릭되어 있지 않을때
        } else {

            axios.post('/api/like/upDisLike', variable)
                .then(response => {
                    if (response.data.success) {

                        setDislikes(Dislikes + 1)
                        setDislikeAction('disliked')

                        // 만약 dislike가 이미 클릭되어 있으면
                        if(LikeAction !== null ) {
                            setLikeAction(null)
                            setLikes(Likes - 1)
                        }

                    } else {
                        alert('Dislike를 올리는데 실패했습니다.')
                    }
                })
        }
    }

    return (
        <React.Fragment>
            <span key="comment-basic-like">
                <Tooltip title="Like">
                    <Icon type="like"
                        theme={LikeAction === 'liked' ? 'filled' : 'outlined'}
                        onClick={onLike} />
                </Tooltip>
                <span style={{ paddingLeft: '8px', cursor: 'auto' }}>{Likes}</span>
            </span>
            &nbsp;&nbsp;
            <span key="comment-basic-dislike">
                <Tooltip title="Dislike">
                    <Icon
                        type="dislike"
                        theme={DislikeAction === 'disliked' ? 'filled' : 'outlined'}
                        onClick={onDisLike}
                    />
                </Tooltip>
                <span style={{ paddingLeft: '8px', cursor: 'auto' }}>{Dislikes}</span>
            </span>
        </React.Fragment>
    )
}

export default LikeDislikes

// server/routes/like.js
const express = require('express')
const router = express.Router()

const { Like } = require('../models/Like')
const { Dislike } = require('../models/Dislike')

//=================================
//             Likes DisLikes
//=================================

router.post('/getLikes', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId }
    } else {
        variable = { commentId: req.body.commentId }
    }

    Like.find(variable)
        .exec((err, likes) => {
            if (err) return res.status(400).send(err)
            res.status(200).json({ success: true, likes })
        })
})

router.post('/getDislikes', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId }
    } else {
        variable = { commentId: req.body.commentId }
    }

    Dislike.find(variable)
        .exec((err, dislikes) => {
            if (err) return res.status(400).send(err)
            res.status(200).json({ success: true, dislikes })
        })
})

router.post('/upLike', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId, userId: req.body.userId }
    } else {
        variable = { commentId: req.body.commentId , userId: req.body.userId }
    }

    // Like Collection에 클릭 정보를 넣어준다.
    const like = new Like(variable)
    like.save((err, likeResult) => {
        if (err) return res.json({ success: false, err })

        // 만약 Dislike이 이미 클릭이 되어 있다면, Dislike를 1 줄여준다
        Dislike.findOneAndDelete(variable)
            .exec((err, disLikeResult) => {
                if (err) return res.status(400).json({ success: false, err })
                res.status(200).json({ success: true })
            })
    })
})

router.post('/unLike', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId, userId: req.body.userId }
    } else {
        variable = { commentId: req.body.commentId , userId: req.body.userId }
    }

    Like.findOneAndDelete(variable)
        .exec((err, result) => {
            if (err) return res.status(400).json({ success: false, err })
            res.status(200).json({ success: true })
        })
})

router.post('/unDisLike', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId, userId: req.body.userId }
    } else {
        variable = { commentId: req.body.commentId , userId: req.body.userId }
    }

    Dislike.findOneAndDelete(variable)
    .exec((err, result) => {
        if (err) return res.status(400).json({ success: false, err })
        res.status(200).json({ success: true })
    })
})

router.post('/upDisLike', (req, res) => {

    let variable = {}
    if (req.body.videoId) {
        variable = { videoId: req.body.videoId, userId: req.body.userId }
    } else {
        variable = { commentId: req.body.commentId , userId: req.body.userId }
    }

    // Dislike Collection에 클릭 정보를 넣어준다.
    const disLike = new Dislike(variable)
    disLike.save((err, dislikeResult) => {
        if (err) return res.json({ success: false, err })

        // 만약 Like이 이미 클릭이 되어 있다면, Like를 1 줄여준다
        Like.findOneAndDelete(variable)
            .exec((err, likeResult) => {
                if (err) return res.status(400).json({ success: false, err })
                res.status(200).json({ success: true })
            })
    })
})

module.exports = router
profile
slowly but surely

0개의 댓글