5장을 시작하기 앞서, 교육과정 중 jsp와 Spring에 대해서 조금 배울예정이기에 결과물을 올리는 텀이 좀 많이 길어질 듯 하다.
그래서 지금껏 만든 결과물까지 일단 올리고, 시간을 가진뒤에 리팩토링과 나머지 기능구현을 추가해보도록 하겠다 !
ps. 사실 댓글(댓글은 websocket이 아니라도 되지만)과 실시간 채팅의 욕심으로 websocket을 정말 많이 검색해봤는데 .. django의 내부 template으로 세팅하는 법은 간단했지만 drf api와 react를 이용해서 구현하는 자료가 많이 없어서 연구하고 내 입맛대로 코드를 변형시키는 시간이 생각보다 너무 길어졌고, 아직까지 엉성하게라도 구현을 성공시키지 못했다. 조금 벽을 느끼는 계기가 됬고 다른언어의 websocket도 공부해보고 python의 문법도 제대로 배워야 가능할것같아서 .. 사실 내 마음에 들게 완성시키는 기간이 많이 길어질 수도 있다.. !
home화면에 새글작성시 최신순으로 contents가 추가되고 작성자의 profile에 따라 content의 view또한 알맞게 작성이 되게끔 frontend와 backend의 설정을 5장에서 진행하겠다.
완성된 모습은 아래와 같다.
보기와같이 유저 profile, datetime등 article을 먼저 만들었고 그 외 좋아요기능과 댓글 수 그리고 오른쪽에 filter기능도 아직 구현하지 못했다.
새글작성 버튼 클릭시 위와같은 경로로 이동하게되며 오른쪽 엉성하지만 미리보기처럼 보이게 또한 해놨다.
profile이미지 업로드 할때처럼 이미지를 미리보기 할 수 있고 home에 추가되기전에 마지막으로 article에 담겨서 보여질 내용을 보이게끔 설계했다. 오른쪽 section에 filter기능을 구현해보기 위해서 일단은 추가해놓은 모습이고 체크박스 형태이다. 후에 기능을 추가해보겠다 !
이제 코드를 살펴보자
먼저 frontend단위부터 살펴보겠다.
import React, { useState, useEffect } from 'react';
import '../css/Board.css';
import '../css/Thumbnail.css'
import { useHistory } from 'react-router'
function Board(props){
let [filterList] = useState([{id : 1, language: 'Python'}, {id : 2, language : 'React'}, {id : 3, language : 'Java'}, {id : 4, language :'C#'}, {id : 5, language:'C'}, {id : 6, language:'C++'}, {id : 7, language:'GO'}, {id : 8, language:'Javascript'}, {id : 9, language:'Html,CSS'}])
let Today = new Date();
let date = Today.getFullYear() + "-" + Today.getMonth() + "-" + Today.getDate()
let [languagefilter, setLanguagefilter] = useState("")
const histoty = useHistory()
let [goback, setGoback] = useState(false)
let [imgGoback, setImgGoback] = useState(false)
let [img , setImg] = useState()
let [imgURL, setImgURL] = useState()
let [title , setTitle] = useState()
let [content , setContent] = useState("")
let [userPhoto, setUserPhoto] = useState()
let sendData;
const handleEffect = (handleSubmit) => {
sendData = {
image : imgURL,
title : title,
content : content,
date : date,
comment : 0,
like : 0,
username : props.user,
language : languagefilter,
profileImage : userPhoto
}
handleSubmit()
}
const handleSubmit = () => {
let form_data = new FormData();
let fileField = document.querySelector('input[type="file"]');
form_data.append('title', sendData.title);
form_data.append('content', sendData.content);
form_data.append('date', sendData.date);
form_data.append('comment', sendData.comment);
form_data.append('language', sendData.language);
form_data.append('like', sendData.like);
form_data.append('username', sendData.username);
form_data.append('image', fileField.files[0])
form_data.append('profileImage', sendData.profileImage)
fetch("http://localhost:8000/api/Todos/", {
method : 'POST',
headers: {
Authorization : `JWT ${localStorage.getItem('token')}`,
},
body : form_data
})
.then(res => res.json())
.catch(error => console.error('Error:', error))
.then(response => console.log('Success:', JSON.stringify(response)));
};
useEffect(()=>{
fetch('http://localhost:8000/user/current/', {
headers: {
Authorization: `JWT ${localStorage.getItem('token')}`
}
})
.then(res => res.json())
.then(json => {
// 현재 유저 정보 받아왔다면, 로그인 상태로 state 업데이트 하고
if (json.id) {
//유저정보를 받아왔으면 해당 user의 프로필을 받아온다.
}fetch('http://localhost:8000/user/auth/profile/' + json.id + '/update/',{
method : 'PATCH',
headers: {
Authorization: `JWT ${localStorage.getItem('token')}`
},
})
.then((res)=>res.json())
.then((userData)=> {
setUserPhoto(userData.photo)
})
.catch(error => {
console.log(error);
});;
}).catch(error => {
console.log(error)
});
},[userPhoto])
return(
<>
{
goback === false
?(
<section className="container-section">
<article className="write-container">
<div className="post-title">
<textarea name="" id="" cols="30" rows="10" placeholder="제목을 입력하세요" onChange={(e)=>{setTitle(e.target.value)}}></textarea>
</div>
<div className="post-contents">
<textarea className="post-textarea" placeholder="내용을 입력하세요" onChange={(e)=>{setContent(e.target.value)}}></textarea>
<div>
<div className="contents-scroll">
<input type="hidden" name="textType" value="HTML" id="textType"></input>
</div>
</div>
</div>
<footer className="post-comment">
<button className="exit-btn transparent-btn" onClick={()=>{histoty.goBack()}}>✔ 나가기</button>
<div>
<button className="transparent-btn" onClick={()=>{
setGoback(true)
}}>발행</button>
</div>
</footer>
</article>
<article className="view-container">
<div className="view-margin">
<h1>{title}</h1>
<div className="view-content">
<div>
<p>
{
content.split('\n').map( (line, i) => {
return (<span key={i}>{line}<br/></span>)
})
}
</p>
</div>
</div>
</div>
</article>
</section>
)
:(
<div className="thumbnail_container">
<div className="thumbnail_section">
<div className="left_section">
<section className="left_container">
<h3>포스트 미리보기</h3>
<button className="upButton">
<label htmlFor="file" className="img-up">
<input type="file" id="file" accept=".jpg, .png, .jpeg, .gif" onChange={(e)=>{
e.preventDefault();
let reader = new FileReader();
let file = e.target.files[0];
reader.onloadend = () => {
setImg(file)
setImgURL(reader.result)
}
reader.readAsDataURL(file);
setImgGoback(true)
}}></input>
이미지 업로드</label></button>
<button className="upButton" onClick={()=>{
setImg(null)
setImgURL(null)
setImgGoback(false)
}}>이미지 제거</button>
<div className="left_container2">
<div className="left_container3">
<div className="img_container">
<div className="img_container2">
{
imgGoback === false
? <svg width="107" height="85" fill="none" viewBox="0 0 107 85"><path fill="#868E96" d="M105.155 0H1.845A1.844 1.844 0 0 0 0 1.845v81.172c0 1.02.826 1.845 1.845 1.845h103.31A1.844 1.844 0 0 0 107 83.017V1.845C107 .825 106.174 0 105.155 0zm-1.845 81.172H3.69V3.69h99.62v77.482z"></path><path fill="#868E96" d="M29.517 40.84c5.666 0 10.274-4.608 10.274-10.271 0-5.668-4.608-10.276-10.274-10.276-5.665 0-10.274 4.608-10.274 10.274 0 5.665 4.609 10.274 10.274 10.274zm0-16.857a6.593 6.593 0 0 1 6.584 6.584 6.593 6.593 0 0 1-6.584 6.584 6.591 6.591 0 0 1-6.584-6.582c0-3.629 2.954-6.586 6.584-6.586zM12.914 73.793a1.84 1.84 0 0 0 1.217-.46l30.095-26.495 19.005 19.004a1.843 1.843 0 0 0 2.609 0 1.843 1.843 0 0 0 0-2.609l-8.868-8.868 16.937-18.548 20.775 19.044a1.846 1.846 0 0 0 2.492-2.72L75.038 31.846a1.902 1.902 0 0 0-1.328-.483c-.489.022-.95.238-1.28.6L54.36 51.752l-8.75-8.75a1.847 1.847 0 0 0-2.523-.081l-31.394 27.64a1.845 1.845 0 0 0 1.22 3.231z"></path></svg>
: <img src={imgURL} alt=""></img>
}
</div>
</div>
</div>
<div className="title-margin">
<h4>{title}</h4>
<textarea name="viewContent">{content}</textarea>
</div>
</div>
</section>
</div>
<div className="line_section"></div>
<div className="right_section">
<div className="fillter_section">
<section>
<ul>
{
filterList.map((a)=>{
return(
<li key={a.id}>
<input id={a.language} className="filters-input__checkbox" value="action" type="checkbox" data-type="genres" onChange={()=>{
let ReLang = [...languagefilter]
ReLang.push(a.language)
setLanguagefilter(ReLang)
}}></input>
<label className="input__label | filters-input__label--checkbox" htmlFor={a.language}>
<span>{a.language}</span>
<span className="filters-input__tick">
<svg focusable="false" aria-hidden="true">
<use xlinkHref="#check">
<svg viewBox="0 0 24 24" id="check" xmlns="http://www.w3.org/2000/svg"><path d="M9 21.035l-9-8.638 2.791-2.87 6.156 5.874 12.21-12.436L24 5.782z"></path></svg>
</use>
</svg>
</span>
</label>
</li>
)
})
}
</ul>
</section>
</div>
<div>
<button className="upButton" onClick={()=>{
handleEffect(handleSubmit)
setGoback(false)
histoty.goBack()
}}>출간하기</button>
</div>
</div>
</div>
</div>
)
}
</>
)
}
export default Board;
사실상 미리보기의 Thumbnail과, Borad를 분리 해야하지만 그냥 붙여놓은 모습이고, 사실상 여지껏 포스팅한 내용의 반복이라 살펴볼 내용은 아래내용정도 인듯하다.
let sendData;
const handleEffect = (handleSubmit) => {
sendData = {
image : imgURL,
title : title,
content : content,
date : date,
comment : 0,
like : 0,
username : props.user,
language : languagefilter,
profileImage : userPhoto
}
handleSubmit()
}
const handleSubmit = () => {
let form_data = new FormData();
let fileField = document.querySelector('input[type="file"]');
form_data.append('title', sendData.title);
form_data.append('content', sendData.content);
form_data.append('date', sendData.date);
form_data.append('comment', sendData.comment);
form_data.append('language', sendData.language);
form_data.append('like', sendData.like);
form_data.append('username', sendData.username);
form_data.append('image', fileField.files[0])
form_data.append('profileImage', sendData.profileImage)
fetch("http://localhost:8000/api/Todos/", {
method : 'POST',
headers: {
Authorization : `JWT ${localStorage.getItem('token')}`,
},
body : form_data
})
.then(res => res.json())
.catch(error => console.error('Error:', error))
.then(response => console.log('Success:', JSON.stringify(response)));
};
image파일을 포함한 FormData를 넘겨야하기때문에 위와같이 form_data의 빈객체를 생성 , 후에 우리가 backend로 넘겨줘야할 객체들을 각각의 내용을 sendData에 정의해주고 그 내용을 append해준뒤에 넘겨주는 모습이다.
특이점은
body : form_data
이처럼 json형태로 변환해주지 않아도 바로 넘겨줄 수 있다는 점에 주목해보면 좋을 듯 하다 !
import React,{useState, useEffect} from 'react';
import {Link} from 'react-router-dom';
import '../css/Trend.css';
function Trend(){
let [filterList] = useState([{id : 1, language: 'Python'}, {id : 2, language : 'React'}, {id : 3, language : 'Java'}, {id : 4, language :'C#'}, {id : 5, language:'C'}, {id : 6, language:'C++'}, {id : 7, language:'GO'}, {id : 8, language:'Javascript'}, {id : 9, language:'Html,CSS'}])
let [todolist, setTodoList] = useState([])
let [userPhoto, setUserPhoto] = useState()
// views.py에서 권한이 없이 데이터조회를 가능하게 했기때문에 posts의 정보를 불러올 수 있다.
useEffect(()=>{
(()=> {
try{
fetch('http://localhost:8000/api/Todos/')
.then((res)=>res.json())
.then((posts)=>{
setTodoList(posts)
})
} catch(e){
console.log(e)
}
})();
},[])
useEffect(()=>{
fetch('http://localhost:8000/user/current/', {
headers: {
Authorization: `JWT ${localStorage.getItem('token')}`
}
})
.then(res => res.json())
.then(json => {
// 현재 유저 정보 받아왔다면, 로그인 상태로 state 업데이트 하고
if (json.id) {
//유저정보를 받아왔으면 해당 user의 프로필을 받아온다.
}fetch('http://localhost:8000/user/auth/profile/' + json.id + '/update/',{
method : 'PATCH',
headers: {
Authorization: `JWT ${localStorage.getItem('token')}`
},
})
.then((res)=>res.json())
.then((userData)=> {
setUserPhoto(userData.photo)
})
.catch(error => {
console.log(error);
});;
}).catch(error => {
console.log(error)
});
},[userPhoto])
return(
<div className="trend-section">
<main className="trend-main">
<div className="main-section">
{
todolist.slice(0).reverse().map((a)=>{
return(
<div className="article" key={a.id}>
<Link to="/">
<div className="arcticle-img">
<img src={"http://localhost:8000" + a.image} alt=""></img>
</div>
</Link>
<div className="article-content">
<Link to="/">
<h4>{a.title}</h4>
<div className="desc-wrapper">
<p>{a.content}</p>
</div>
</Link>
<div className="sub-info">
<span>{a.date}</span>
<span className="separator">·</span>
<span>{a.comment}개의 댓글</span>
</div>
</div>
<div className="article-footer">
<Link to="/">
<img src={a.profileImage} alt=""></img>
<span>"by " <b>{a.username}</b></span>
</Link>
<div className="likes">
<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M18 1l-6 4-6-4-6 5v7l12 10 12-10v-7z"></path></svg>
{a.like}
</div>
</div>
</div>
)
})
}
</div>
</main>
<aside className="pDRpR">
<div className="eyrfCG">
<div className="filter__head">
</div>
<section>
<ul>
{
filterList.map((a)=>{
return(
<li key={a.id}>
<input id={a.language} className="filters-input__checkbox" value="action" type="checkbox" data-type="genres"></input>
<label className="input__label | filters-input__label--checkbox" htmlFor={a.language}>
<span>{a.language}</span>
<span className="filters-input__tick">
<svg focusable="false" aria-hidden="true">
<use xlinkHref="#check">
<svg viewBox="0 0 24 24" id="check" xmlns="http://www.w3.org/2000/svg"><path d="M9 21.035l-9-8.638 2.791-2.87 6.156 5.874 12.21-12.436L24 5.782z"></path></svg>
</use>
</svg>
</span>
</label>
</li>
)
})
}
</ul>
</section>
</div>
</aside>
</div>
)
}
export default Trend;
이코드는 home화면에서 Navi의 최신항목을 선택중일 때 표시될 content를 Trend라고 설정해놨다. 사실 살펴볼 만한 코드는 아래코드 밖에 없다.
todolist.slice(0).reverse().map()
사실 나도 처음에는 별 생각이없었는데 새 글 작성 test시 저장되고 불러올 때 순서가 최신순서가 아닌 처음 작성한 view가 먼저 보이게 되니, 역순으로 출력하기 위해서 map함수에 slice(0).reverse()로 역전시켜준 모습이다 !
Navi.js에는 content에 맞게끔 component가 페이지전환없이 바뀔 수 있도록 설정해준 모습이다.
import React, { useState } from 'react';
import {Link} from 'react-router-dom';
import Trend from './Trend'
import '../css/Navi.css';
function Navi(){
let [underline, setUnderline] = useState({left:"0%"})
let [choseContent, setChoseContent] = useState(<Trend/>)
return(
<>
<div className="navi-container">
<div className="navi-box">
<Link className="navi-" to="/" onClick={()=>{
setUnderline({left:"0%"})
setChoseContent(<Trend/>)
}}>
<span role = "img" aria-label = "하트">🤞최신</span>
</Link>
<Link className="navi-" to="/" onClick={()=>{
setUnderline({left:"50%"})
setChoseContent("")
}}>
<span role = "img" aria-label = "질문">🤷♂️Q & A</span>
</Link>
<div className="navi-underline" style={underline}></div>
</div>
</div>
{choseContent}
</>
)
}
export default Navi;
이제 backend 코드를 살펴볼건데 사실 여지껏 했던 내용의 반복, 조금씩의 변형일 뿐이다.
우선 post라는 app을 startapp만들어주고 setting.py 에 추가해준 뒤 모델먼저 보겠다 !
from django.db import models
class Todo(models.Model):
title = models.CharField(max_length=50)
content = models.TextField()
image = models.ImageField(upload_to='post_images', blank=True)
date = models.DateTimeField(auto_now_add=True)
comment = models.IntegerField(default=0)
like = models.IntegerField(default=0)
username = models.CharField(max_length=200)
language = models.CharField(max_length=200)
profileImage = models.CharField(default='/media/red.jpg', max_length=500)
def __str__(self):
return self.title
migrate잊지말자 ~
from rest_framework import serializers
from .models import Todo
class TodoSerializer(serializers.ModelSerializer):
class Meta:
model = Todo
fields = '__all__'
사실 살펴볼 내용은 views.py에 두줄밖에 없다. 우선 보자
from .serializers import TodoSerializer
from .models import Todo
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework import status
# Create your views here.
class TodoView(APIView):
parser_classes = (MultiPartParser, FormParser)
authentication_classes = [] #이거 두줄은 권한이 없는 상태에서 데이테 요청을 가능하게
permission_classes = [] #만듬 settings.py에서도 아마 가능할 것 같음.
def get(self, request, *args, **kwargs):
Todos = Todo.objects.all()
serializer = TodoSerializer(Todos, many=True)
return Response(serializer.data)
def post(self, request, *args, **kwargs):
Todos_serializer = TodoSerializer(data=request.data)
if Todos_serializer.is_valid():
Todos_serializer.save()
return Response(Todos_serializer.data, status=status.HTTP_201_CREATED)
else:
print('error', Todos_serializer.errors)
return Response(Todos_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
주석처리해놓은 부분!
user가 작성한 post는 원래 권한없이는 get등으로 조회가 불가능한데
저 두줄으로인해 get방식등의 데이터요청을 가능하게 만들어 Trend.js에서 가져오기를 할 수 있어 로그인하지않은 유저들 또한 post를 볼 수 있게됬다 !
from django.urls import path
from . import views
urlpatterns = [
path('Todos/', views.TodoView.as_view(), name= 'Todos_list'),
]
from django.contrib import admin
from django.urls import path, include
from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token
from .views import validate_jwt_token
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path('validate/', validate_jwt_token),
path('login/', obtain_jwt_token),
path('verify/', verify_jwt_token),
path('refresh/', refresh_jwt_token),
path('user/', include('user.urls')),
path('api/', include('post.urls')),
]
urlpatterns += \
static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
admin 페이지도 필요하다면 추가해주면 될 듯하다 !
이렇게 5장까지 직접 velog를 따라하면서 따라하기 위해서 무엇이 필요한지, 어떤 검색을 해야하는지 어떻게 접근해야하는지 등 혼자 공부하며 많은 시행착오를 통해 많은 공부가 됬었다. 이제는 리팩토링 그리고 아직 구현 못한 기능들을 추가하면서 spring과 react의 redux등 공부를 하며 천천히 공부한 내용을 하나씩 올리겠다 !
framework를 이번에 처음 접하게되어 아무 강의없이 혼자서 검색하며 코드를 분석하고 헤딩하며 많은 시행착오를 겪었다.
덕분에 framework는 어떤 규칙이 분명하게 정해져있고, 우리는 규칙에 벗어나지 않게 class들을 활용하면서 입맛에 바꾸는 , 상당히 유용한 시간이었다.
나는 맨땅에 헤딩하며, 공부의 필요성을 많이 느꼈고, 천천히 간다면 남들보다 뛰어나진 않아도 남들과 비슷한 수준까지 성장 할 수 있다는 자신감을 velog copy 프로젝트를 진행하면서 얻었다.