React

 

 

 

John Ahn

 

인프런   ==>     라하며 배우는 노드, 리액트 시리즈 - 유튜브 사이트 만들기

 

 

유튜브 강의 목록 :   https://www.youtube.com/playlist?list=PL9a7QRYt5fqnlSRu--re7N_1Ean5jFsh3

 

강의 파일 : https://cdn.jsdelivr.net/gh/braverokmc79/react-youtube-clone@v1.0.0/Youtube%20Clone%20(Ko).pdf

 

Boiler Plate  소스 https://github.com/braverokmc79/ReactYoutubeCloneSeries

완성본 소스 (John Ahn:  https://github.com/jaewonhimnae/react-youtube-clone

소스 https://github.com/braverokmc79/react-youtube-clone

 

 

강의 목록

1.유튜브 사이트 만들기
2.전체적인 틀 만들고 MongoDB 연결
3.비디오 업로드 FORM 만들기1
4.비디오 업로드 FORM 만들기2
5.Multer로 노드 서버에 비디오 저장하기
6. ffmpeg로 비디오 썸네일 생성하기
7.비디오 업로드 하기
8.랜딩 페이지에 비디오들 나타나게 하기
9.비디오 디테일 페이지 만들기
10.디테일 비디오 페이지에 Side 비디오 생성
11.구독 기능 1
12.구독 기능 2
13.구독 비디오 페이지
14.댓글 기능 생성 1 구조 설명
15.댓글 기능 생성 2 Comment js
16.댓글 기능 생성 3 SingleComment
17.댓글 기능 생성 4 ReplyComment
18.좋아요 싫어요 기능1 구조 설명
19.좋아요 싫어요 기능2 템플릿, 데이터 가져오기
20.좋아요 싫어요 기능 3 클릭시 기능들
 

 

 

 

 

3.비디오 업로드 FORM 만들기1

 

 

 

 

VideoUploadPage.js

import React from 'react'
import { Typography, Button, Form, message, Input, Icon } from 'antd';
import Title from 'antd/lib/typography/Title';
import TextArea from 'antd/lib/input/TextArea';
import Dropzone from 'react-dropzone'

function VideoUploadPage() {
    return (
        <div style={{ maxWidth: '700px', margin: '2rem auto' }}>
            <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
                <Title level={2}>업로드 비디오</Title>
            </div>

            <Form >
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                    {/* Drop zone  */}


                    <Dropzone multiple >
                        {({ getRootProps, getInputProps }) => (
                            <div style={{
                                width: "100%", height: 240, border: '1px solid lightgray',
                                alignItems: 'center', justifyContent: 'center', display: 'flex'
                            }} {...getRootProps()}>


                                <input {...getInputProps()} />

                                <Icon type="plus" style={{ fontSize: '3rem' }} />


                            </div>
                        )}
                    </Dropzone>

                    {/*Thumbnail */}
                </div>

                <div>
                    <img src alt />
                </div>



                <br /><br /><br />
                <label>제목</label>
                <Input />

                <br /><br /><br />
                <label>내용</label>
                <TextArea />

                <br /><br /><br />

                <select onChange>
                    <option key="1" value>1</option>
                </select>

                <br /><br /><br />
                <select onChange>
                    <option key="1" value>1</option>
                </select>

                <br /><br /><br />
                <Button size='large' onClick type='primary'>
                    전송
                </Button>



            </Form>



        </div >

    )
};


export default VideoUploadPage;

 

 

 

 

 

4.비디오 업로드 FORM 만들기1

 

VideoUploadPage.js

import React from 'react'
import { Typography, Button, Form, message, Input, Icon } from 'antd';
import Title from 'antd/lib/typography/Title';
import TextArea from 'antd/lib/input/TextArea';
import Dropzone from 'react-dropzone'
import { useState } from 'react';
import { Select } from 'antd';
const { Option } = Select;

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

const CateogryOptions = [
    { value: 0, label: "Film & Animation" },
    { value: 1, label: "Autos & Vehicles" },
    { value: 2, label: "Music" },
    { value: 3, label: "Pets & Animals" }
]

function VideoUploadPage() {

    const [VideoTitle, setVideoTitle] = useState("");
    const [Description, setDescription] = useState("");
    const [Private, setPrivate] = useState(0);
    const [Category, setCategory] = useState("Film & Animation");


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

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

    const onPrivateChange = (e) => {
        setPrivate(e);
    }

    const onCategoryChange = (e) => {
        setCategory(e);
    }

    return (
        <div style={{ maxWidth: '700px', margin: '2rem auto' }}>
            <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
                <Title level={2}>업로드 비디오</Title>
            </div>

            <Form >
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                    {/* Drop zone  */}


                    <Dropzone multiple >
                        {({ getRootProps, getInputProps }) => (
                            <div style={{
                                width: "100%", height: 240, border: '1px solid lightgray',
                                alignItems: 'center', justifyContent: 'center', display: 'flex'
                            }} {...getRootProps()}>


                                <input {...getInputProps()} />

                                <Icon type="plus" style={{ fontSize: '3rem' }} />


                            </div>
                        )}
                    </Dropzone>

                    {/*Thumbnail */}
                </div>

                <div style={{ display: "none" }}>
                    <img alt="" />
                </div>



                <br /><br /><br />
                <label>제목</label>
                <Input onChange={onTitleChange}
                    value={VideoTitle}
                />


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

                <br /><br /><br />

                <Select onChange={onPrivateChange} style={{ width: '20%' }}>
                    {PrivateOptions.map((item, index) => (
                        <Option key={index} value={item.value}>{item.label}</Option>
                    ))}
                </Select>

                <br /><br /><br />

                <Select onChange={onCategoryChange} style={{ width: '20%' }}>
                    {CateogryOptions.map((item, index) => (
                        <Option key={index} value={item.value}>{item.label}</Option>
                    ))}
                </Select>

                <br /><br /><br />
                <Button size='large' type='primary'>
                    전송
                </Button>



            </Form>



        </div >

    )
};


export default VideoUploadPage;

 

 

 

 

5.Multer로 노드 서버에 비디오 저장하기

 

 

1)Nodejs 서버     

파일 업로드 모듈 multer 설치
$ yarn add multer

 

index.js

app.use("/api/video", require("./routes/video"))

routes/video.js

const express = require('express');
const router = express.Router();
//const { Video } = require("../models/Video");
const { auth } = require("../middleware/auth");
const multer = require("multer");

//STORAGE MULTER CONFIG
const storage = multer.diskStorage({

    destination: (req, file, callBack) => {
        callBack(null, "uploads/");
    },

    filename: (req, file, callBack) => {
        callBack(null, `${Date.now()}_${file.originalname}`);
    }
});

const fileFilter = (req, file, callBack) => {
    // mime type 체크하여 원하는 타입만 필터링
    console.log("fileFilter... ");

    if (file.mimetype === 'video/mp4') {
        callBack(null, true);
    } else {
        callBack({ msg: 'mp4 파일만 업로드 가능합니다.' }, false);
    }
}


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


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

    upload(req, res, err => {
        if (err) {
            return res.send({ success: false, err })
        } else {
            console.log("서버업로드파일내용 : ", req.file);
            req.file.success = true;
            return res.send(req.file);
        }
    })
});



module.exports = router;

 

 

 

2)Reactjs  - VideoUploadPage.js

import React from 'react'
import { Button, Form, Input, Icon } from 'antd';
import Title from 'antd/lib/typography/Title';
import TextArea from 'antd/lib/input/TextArea';
import Dropzone from 'react-dropzone'
import { useState } from 'react';
import { Select } from 'antd';
import axios from "axios";
const { Option } = Select;


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

const CateogryOptions = [
    { value: 0, label: "Film & Animation" },
    { value: 1, label: "Autos & Vehicles" },
    { value: 2, label: "Music" },
    { value: 3, label: "Pets & Animals" }
]

function VideoUploadPage() {

    const [VideoTitle, setVideoTitle] = useState("");
    const [Description, setDescription] = useState("");
    const [Private, setPrivate] = useState(0);
    const [Category, setCategory] = useState("Film & Animation");


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

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

    const onPrivateChange = (e) => {
        setPrivate(e);
    }

    const onCategoryChange = (e) => {
        setCategory(e);
    }

    const onDrop = (files) => {

        let formData = new FormData();
        const config = {
            header: { 'Content-Type': 'multipart/form-data' }
        }
        console.log("1.업로드 전(files ):", files[0]);
        formData.append("file", files[0]);

        axios.post("/api/video/uploadfiles", formData, config).then(res => {
            if (res.data.success) {
                console.log("2.업로드 후(files ): ", res.data);
                // destination: "uploads/"
                // encoding: "7bit"
                // fieldname: "file"
                // filename: "1662355212218_sample.mp4"
                // mimetype: "video/mp4"
                // originalname: "sample.mp4"
                // path: "uploads\\1662355212218_sample.mp4"
                // size: 10198832
                // success: true
            } else {
                alert("업로드에 실패하였습니다.");
            }
        }).catch(err => {
            console.log("에러 :", err);
            alert(err.message);
        })

    }


    return (
        <div style={{ maxWidth: '700px', margin: '2rem auto' }}>
            <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
                <Title level={2}>업로드 비디오</Title>
            </div>

            <Form >
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                    {/* Drop zone  */}


                    <Dropzone multiple={false} onDrop={onDrop} maxSize={1000000000000} >
                        {({ getRootProps, getInputProps }) => (
                            <div style={{
                                width: "100%", height: 240, border: '1px solid lightgray',
                                alignItems: 'center', justifyContent: 'center', display: 'flex'
                            }} {...getRootProps()}>


                                <input {...getInputProps()} />

                                <Icon type="plus" style={{ fontSize: '3rem' }} />


                            </div>
                        )}
                    </Dropzone>

                    {/*Thumbnail */}
                </div>

                <div style={{ display: "none" }}>
                    <img alt="" />
                </div>



                <br /><br /><br />
                <label>제목</label>
                <Input onChange={onTitleChange}
                    value={VideoTitle}
                />


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

                <br /><br /><br />

                <Select onChange={onPrivateChange} style={{ width: '20%' }}>
                    {PrivateOptions.map((item, index) => (
                        <Option key={index} value={item.value}>{item.label}</Option>
                    ))}
                </Select>

                <br /><br /><br />

                <Select onChange={onCategoryChange} style={{ width: '20%' }}>
                    {CateogryOptions.map((item, index) => (
                        <Option key={index} value={item.value}>{item.label}</Option>
                    ))}
                </Select>

                <br /><br /><br />
                <Button size='large' type='primary'>
                    전송
                </Button>



            </Form>



        </div >

    )
};


export default VideoUploadPage;

 

 

 

 

 

 

 

6. ffmpeg로 비디오 썸네일 생성하기

 

 

 => 윈도우 FFmpeg 설치 및 사용 방법

 

 

설치후 pc 재부팅  할것.

 

 

 

 nodejs 에 fluent-ffmpeg 듈 설치

$ yarn add fluent-ffmpeg

 

 

 

1.nodejs  서버

 

1)index.js   

/uploads  express.static  설정

app.use("/api/video", require("./routes/video"));
app.use('/uploads', express.static('uploads'));

 

2) video.js

const express = require('express');
const router = express.Router();
//const { Video } = require("../models/Video");
const { auth } = require("../middleware/auth");
const multer = require("multer");
const ffmpeg = require("fluent-ffmpeg");

~
생략
~


// thumbnail 생성하기
router.post("/thumbnail", (req, res) => {
    //썸네일 생성하고  비디오 러닝타임도 가져오기
    let filePath = "";
    let fileDuration = "";
    let filename = "";

    //1.비디오 전체 정보 추출
    ffmpeg.ffprobe(req.body.url, function (err, metadata) {
        // console.dir(metadata);
        // console.log(metadata.format.duration);
        fileDuration = metadata.format.duration;
    });


    //2.썸네일 생성
    ffmpeg(req.body.url)
        .on('filenames', function (filenames) {
            console.log('Will generate ' + filenames.join(', '));
            console.log("filenames : ", filenames);

            filePath = "uploads/thumbnails/" + filenames[0];
            filename = filenames[0];

        })
        .on('end', function () {
            console.log("1.Screenshots .on('filenames') filePath:", filePath);
            console.log("2.Screenshots .on('filenames') filename:", filename);
            console.log("3.Screenshots - ffprobe 변수값 fileDuration :", fileDuration);

            return res.send({ success: true, url: filePath, fileDuration: fileDuration, filename: filename })
        })
        .on('error', function (err) {
            console.error(err);
            return res.send({ success: false, err: err });
        })
        .screenshots({
            // Will take screens at 20%, 40%, 60% and 80% of the video
            count: 3,
            folder: 'uploads/thumbnails',
            size: '320x240', //'320x240'
            // %b input basename ( filename w/o extension )
            filename: 'thumbnail-%b.png'
        });


});



module.exports = router;

 

 

2)Reactjs 프론트

VideoUploadPage.js

 ~ 생략

~

    const onDrop = (files) => {

        let formData = new FormData();
        const config = {
            header: { 'Content-Type': 'multipart/form-data' }
        }
        console.log("1.업로드 전(files ):", files[0]);
        formData.append("file", files[0]);

        Axios.post("/api/video/uploadfiles", formData, config).then(res => {
            if (res.data.success) {
                console.log("2.업로드 후(files ): ", res.data);
                // destination: "uploads/"
                // encoding: "7bit"
                // fieldname: "file"
                // filename: "1662355212218_sample.mp4"
                // mimetype: "video/mp4"
                // originalname: "sample.mp4"
                // path: "uploads\\1662355212218_sample.mp4"
                // size: 10198832
                // success: true
                let variable = {
                    url: res.data.destination + res.data.filename,
                    fieldName: res.data.fieldname,
                }

                setFilePath(variable.url);

                Axios.post('/api/video/thumbnail', variable)
                    .then(res => {
                        if (res.data.success) {
                            console.log("썸네일 :", res.data);
                            setDuration(res.data.fileDuration);
                            setThumbnailPath(res.data.url);

                        } else {
                            alert("썸네일 생성에 실패 했습니다.");
                        }
                    });

            } else {
                alert("업로드에 실패하였습니다.");
            }
        }).catch(err => {
            console.log("에러 :", err);
            alert(err.message);
        })

    }



~

생략

~



     <Form >
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                    {/* Drop zone  */}


                    <Dropzone multiple={false} onDrop={onDrop} maxSize={1000000000000} >
                        {({ getRootProps, getInputProps }) => (
                            <div style={{
                                width: "100%", height: 240, border: '1px solid lightgray',
                                alignItems: 'center', justifyContent: 'center', display: 'flex'
                            }} {...getRootProps()}>

                                <input {...getInputProps()} />

                                <Icon type="plus" style={{ fontSize: '3rem' }} />

                            </div>
                        )}
                    </Dropzone>

                    {/*Thumbnail */}

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



~

~

 ~ 생략

~
~

 

 

 

 

 

 

 

7.비디오 업로드 하기

 

 

 

1. React.js

VideoUploadPage.js

import { useSelector } from 'react-redux';
~
생략
~

function VideoUploadPage() {
    const user = useSelector(state => state.user);


~
생략
~

    const onSubmit = (e) => {
        e.preventDefault();
        const variables = {
            writer: user.userData._id,
            title: VideoTitle,
            description: Description,
            privacy: Private,
            filePath: FilePath,
            category: Category,
            duration: Duration,
            thumbnail: ThumbnailPath,
        }


        Axios.post("/api/video/uploadVideo", variables)
            .then(res => {
                if (res.data.success) {
                    message.success('성공적으로 업로드를 했습니다.');
                    setTimeout(() => {
                        navigate("/");
                    }, 3000);
                } else {
                    alert('비디오 업로드에 실패 했습니다.');
                }
            })
    }
~
생략
~

 

 

 

 

2. Node.js 서버

1)routes

router.post('/uploadVideo', (req, res) => {
    //비디오 정보들을 저장한다.
    const video = new Video(req.body);
    video.save((err, doc) => {
        if (err) return res.json({ success: false, err });
        res.status(200).json({ success: true })
    })


})

 

2)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 }

 

 

 

 

 

 

 

 

 

8.랜딩 페이지에 비디오들 나타나게 하기

 

 

 

 

리액트 : LandingPage.js

import React, { useEffect } from 'react'
import { FaCode } from "react-icons/fa";
import { Button, Form, Input, Icon, message, Row, Col, Avatar } from 'antd';
import Title from 'antd/lib/typography/Title';
import Meta from 'antd/lib/card/Meta';
import { useSelector } from 'react-redux';
import Axios from 'axios';
import { useState } from 'react';
import moment from 'moment';
import { Link } from 'react-router-dom';

function LandingPage() {

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

    useEffect(() => {
        Axios.get("/api/video/getVideos")
            .then(res => {
                if (res.data.success) {
                    console.log("getVideos :", res.data);
                    setVideos(res.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 lg={6} md={8} xs={24} key={video._id}>
            <div style={{ position: 'relative' }}>
                <Link to={`/video/${video._id}`} >
                    <img style={{ width: '100%' }} alt="thumbnail" src={`http://localhost:5000/${video.thumbnail}`} />
                    <div className=" duration"
                        style={{
                            bottom: 0, right: 0, position: 'absolute', margin: '4px',
                            color: '#fff', backgroundColor: 'rgba(17, 17, 17, 0.8)', opacity: 0.8,
                            padding: '2px 4px', borderRadius: '2px', letterSpacing: '0.5px', fontSize: '12px',
                            fontWeight: '500', lineHeight: '12px'
                        }}>
                        <span>{minutes} : {seconds}</span>
                    </div>
                </Link>
            </div><br />
            <Meta
                avatar={
                    <Avatar src={video.writer.image} />
                }
                title={video.title}
            />
            <span>{video.writer.name} </span><br />
            <span style={{ marginLeft: '3rem' }}> {video.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

 

 

 

nodejs 서버

//비디오를 DB 에서 가져와서 클라이언트에 보낸다.
//populate 사용해야지 모든 정보를 가져올수 있다.
router.get("/getVideos", (req, res) => {
    Video.find()
        .populate('writer')
        .exec((err, videos) => {
            if (err) return res.status(400).send(err);
            res.status(200).json({ success: true, videos });
        });
});

 

 

 

 

 

 

 

 

 

9.비디오 디테일 페이지 만들기

 

 

 

 

리액트 : VideoDetailPage.js

import React, { useEffect } from 'react';
import { Row, Col, List, Avatar } from 'antd';
import { useParams } from 'react-router-dom';
import Axios from 'axios';
import { useState } from 'react';


function VideoDetailPage() {

    const { videoId } = useParams();
    const [VideoDetail, setVideoDetail] = useState([]);
    const variable = { videoId: videoId };

    useEffect(() => {


        Axios.post(`/api/video/getVideoDetail/`, variable)
            .then(res => {
                if (res.data.success) {
                    setVideoDetail(res.data.videoDetail);
                } else {
                    alert("비디오 정보를 가져오는데 실패했습니다.");
                }

            });

    }, []);


    if (VideoDetail.writer) {
        return (
            <Row gutter={[16, 16]}>
                <Col lg={18} xs={24}>
                    <div style={{ width: '100%', padding: '3rem 4rem' }}>
                        <video style={{ width: '100%' }} src={`http://localhost:5000/${VideoDetail.filePath}`} controls />
                        <List.Item>
                            <List.Item.Meta
                                avatar={<Avatar src={VideoDetail.writer && VideoDetail.writer.image} />}
                                title={VideoDetail.writer.name}
                                description={VideoDetail.description}
                            />
                        </List.Item>

                        {/* comments */}
                    </div>
                </Col>

                <Col lg={6} xs={24}>
                    Side Videos
                </Col>

            </Row>
        )

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

export default VideoDetailPage

 

 

 

Nodejs :   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);
            res.status(200).json({ success: true, videoDetail });
        });

});

 

 

 

 

 

 

 

 

 

 

 

 

 

 

10.디테일 비디오 페이지에 Side 비디오 생성

 

 

 

 

 

VideoDetailPage/Section/SideVideo.js

/* eslint-disable jsx-a11y/anchor-is-valid */
import React, { useEffect } from 'react'
import { useState } from 'react';
import Axios from 'axios';

function SideVideo(props) {

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


    useEffect(() => {
        Axios.get("/api/video/getVideos")
            .then(res => {
                if (res.data.success) {
                    console.log("SideVideos :", res.data);
                    setSideVideos(res.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',
                        marginBottom: "1rem",
                        padding: '0 2rem',
                    }}>
                    <div style={{ width: '50%', marginBottom: '1rem', padding: '0 2rem' }}>
                        <a href="#" onClick={(e) => {
                            e.preventDefault();
                            props.movePage(`${video._id}`)
                        }} >
                            <img style={{ width: '100%' }} src={`http://localhost:5000/${video.thumbnail}`} alt={video.title} />
                        </a>
                    </div>

                    <div style={{ width: '50%' }}>
                        <a href="#"
                            onClick={(e) => {
                                e.preventDefault();
                                props.movePage(`${video._id}`)
                            }}
                            style={{ color: 'gray' }}>
                            <span style={{ fontSize: '1rem', color: 'black' }} >{video.title}</span><br />
                            <span>{video.writer.name}</span><br />
                            <span>{video.views}</span><br />
                            <span>{minutes} : {seconds}</span>

                        </a>
                    </div>

                    <div>

                    </div>
                </div >
            )
        });



    return <React.Fragment >
        {renderSideVideo}
    </React.Fragment>

}

export default SideVideo

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

Where there is life, there is hope. (살아 있는 한 희망이 있다.)

댓글 ( 4)

댓글 남기기

작성