게시글에 댓글 달기 구현
CommentVo.java
먼저 CommentVo를 만들어준다
CommentVo는 CommentEntity를 상속받으며 상속 받은 것들에 추가적으로 userNickname을 추가한다.
게시글 읽기 + DB에 인서트 하기 에서 ArticleReadVo를 만들어 줬던 것과 같은 맥락임.
그리고 getter & setter 추가
package dev.xowoony.studymemberbbs.vos.bbs;
import dev.xowoony.studymemberbbs.entities.bbs.CommentEntity;
public class CommentVo extends CommentEntity {
private String userNickname;
private boolean isSigned;
private boolean isMine;
private boolean isLiked;
public boolean isLiked() {
return isLiked;
}
public void setLiked(boolean liked) {
isLiked = liked;
}
public boolean isMine() {
return isMine;
}
public void setMine(boolean mine) {
isMine = mine;
}
public boolean isSigned() {
return isSigned;
}
public void setSigned(boolean signed) {
isSigned = signed;
}
public String getUserNickname() {
return userNickname;
}
public void setUserNickname(String userNickname) {
this.userNickname = userNickname;
}
}
BbsMapper.xml
resultType 은 CommentVo를,
select 쿼리를 이용하여 `study_bbs`.`comments` 테이블에 있는 정보를 가져오고
LEFT JOIN을 이용해 옆동네 애들 중 `user`.`nickname`을 데려온다
그저 사용자의 이메일 대신 닉네임을 표시하기 위함임.
<select id="selectCommentsByArticleIndex"
resultType="dev.xowoony.studymemberbbs.vos.bbs.CommentVo">
SELECT `index` AS `index`,
`comment_index` AS `commentIndex`,
`user_email` AS `userEmail`,
`article_index` AS `articleIndex`,
`content` AS `content`,
`written_on` AS `writtenOn`,
`user`.`nickname` AS `userNickname`
FROM `study_bbs`.`comments` AS `comment`
LEFT JOIN `study_member`.`users` AS `user` ON `comment`.`user_email` = `user`.`email`
WHERE `comment`.`article_index` = #{articleIndex}
ORDER BY `index`
</select>
IBbsMapper.java (인터페이스)
@Param을 이용하여 articleIndex를 배열로 가져오도록 한다.
배열일 경우에 이름지을 때 아래와 같이 selectCommentsByArticleIndex 이런식으로 복수형으로 지어주도록 한다.
CommentVo[] selectCommentsByArticleIndex(@Param(value="articleIndex") int articleIndex);
//배열일 경우 복수형으로 이름지어야 한다. (CommentsBy~뭐시기)
참고로 CommentVo를 만들기 전은 이런 모양이었음.
CommentEntity[] selectCommentsByArticleIndex(@Param(value = "articleIndex") int articleIndex);
BbsController.java
commentObject 를 생성하고
index~userNickname 등등을 집어넣고
또 그것을 responseArray에 집어 넣는다.
마지막으로 배열을 반환한다.(string)
향상된 for문을 사용하며
한바퀴씩 돌면서 댓글 하나가 생성된다.
@RequestMapping(value = "comment",
method = RequestMethod.GET,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getComment(@SessionAttribute(value = "user", required = false) UserEntity user,
@RequestParam(value = "aid") int articleIndex) {
JSONArray responseArray = new JSONArray();
CommentVo[] comments = this.bbsService.getComments(articleIndex);
for (CommentVo comment : comments) {
JSONObject commentObject = new JSONObject();
commentObject.put("index", comment.getIndex());
commentObject.put("commentIndex", comment.getCommentIndex());
commentObject.put("userEmail", comment.getUserEmail());
commentObject.put("articleIndex", comment.getArticleIndex());
commentObject.put("content", comment.getContent());
commentObject.put("writtenOn", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(comment.getWrittenOn()));
commentObject.put("userNickname", comment.getUserNickname());
commentObject.put("isSigned", user != null);
commentObject.put("isMine", user != null && user.getEmail().equals(comment.getUserEmail()));
commentObject.put("isLiked", comment.isLiked());
responseArray.put(commentObject);
}
return responseArray.toString();
}
bbsService.java
사용자가 어느 게시글에 댓글을 달았는지 알아야 하기 때문에 (article_index) 를 리턴한다.
// 댓글 쓰기
public CommentVo[] getComments(int articleIndex) {
return this.bbsMapper.selectCommentsByArticleIndex(articleIndex);
}
read.html
<tr class="comment-row">
<td colspan="6">
<form class="comment-form" id="commentForm">
<label class="label">
<span hidden>댓글작성</span>
<input class="--object-input" id="objectInput" maxlength="100" name="content"
placeholder="댓글을 입력해 주세요" type="text">
</label>
<input type="hidden" th:value="${article.getIndex()}" name="aid">
<input class="--object-button" type="submit" value="작성">
</form>
<div class="comment-container" id="commentContainer">
<div class="comment liked mine">
<div class="head">
<span class="writer">관리자</span>
<span class="dt">2022-01-01 00:00:00</span>
<span class="spring"></span>
<span class="action-container">
<a class="action reply" href="#" rel="actionReply">답글 달기</a>
<a class="action modify" href="#" rel="actionModify">수정</a>
<a class="action delete" href="#" rel="actionDelete">삭제</a>
<a class="action cancel" href="#" rel="actionCancel">취소</a>
</span>
</div>
<div class="body">
<div class="content">
<span class="text">댓글 내용임!</span>
<div class="like">
<a href="#" class="toggle">
<i class="icon fa-solid fa-heart"></i>
</a>
<span class="count">9,999</span>
</div>
</div>
<form class="modify-form">
<label class="label">
<span hidden>댓글 수정</span>
<input class="--object-input" maxlength="100" name="content"
placeholder="댓글을 입력해 주세요" type="text">
</label>
<input class="--object-button" type="submit" value="수정">
</form>
</div>
</div>
<form class="reply-form" id="replyForm">
<div class="head">
<span class="to">@관리자</span>
<a href="#" class="cancel">취소</a>
</div>
<div class="body">
<label class="label">
<span hidden>답글작성</span>
<input class="--object-input" maxlength="100" name="content"
placeholder="답글을 입력해 주세요" type="text">
</label>
<input class="--object-button" type="submit" value="작성">
</div>
</form>
<div class="comment sub">
<div class="head">
<span class="writer">누구</span>
<span class="dt">2022-01-01 00:00:00</span>
<span class="spring"></span>
<span class="action-container">
<a class="action reply" href="#">답글달기</a>
<a class="action delete" href="#">삭제</a>
</span>
</div>
<div class="body">zz</div>
</div>
<div class="comment sub mine">
<div class="head">
<span class="writer">누구</span>
<span class="dt">2022-01-01 00:00:00</span>
<span class="spring"></span>
<span class="action-container">
<a class="action reply" href="#">답글달기</a>
<a class="action delete" href="#">삭제</a>
</span>
</div>
<div class="body">ss</div>
</div>
</div>
</td>
</tr>
read.css
@charset "UTF-8";
body > .main > .content {
margin: 5rem 0;
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
body > .main > .content > .title {
font-size: 2rem;
font-weight: 500;
margin-bottom: 2.5rem;
}
body > .main > .content > .table > thead td,
body > .main > .content > .table > thead th {
padding: 0.5rem 0.75rem;
vertical-align: middle;
}
body > .main > .content > .table > thead td {
padding: 0.5rem;
}
body > .main > .content > .table th {
width: 0;
background-color: rgb(255, 255, 255);
border-radius: 0.5rem;
color: rgb(128, 139, 150);
font-weight: 400;
text-align: center;
white-space: nowrap;
}
body > .main > .content > .table .content-container {
background-color: rgb(255, 255, 255);
border-radius: 0.5rem;
margin: 0.75rem 0;
padding: 1rem 2rem;
}
body > .main > .content > .table .button-container {
align-items: stretch;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
body > .main > .content > .table .button-container > * + * {
margin-left: 0.5rem;
}
body > .main > .content > .table .button-container > .spring {
flex: 1;
}
body > .main > .content > .table .button-container > a[href] {
text-decoration: none;
}
body > .main > .content > .table .button-container > a[href].modify {
background-color: rgb(40, 117, 225);
}
body > .main > .content > .table .button-container > a[href].delete {
background-color: rgb(231, 76, 60);
}
#commentForm {
margin-top: 0.5rem;
align-items: stretch;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
#commentForm > .label {
flex: 1;
margin-right: 0.5rem;
}
/*댓글 전체 간격 margin-top*/
#commentContainer {
margin-top: 0.75rem;
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/*댓글들 사이사이 간격 margin-top*/
#commentContainer > * + * {
margin-top: 0.5rem;
}
/*댓글창 각각의 모양*/
#commentContainer .comment {
border-radius: 0.375rem;
box-shadow: 0 0 0.5rem 0.0625rem rgba(0, 0, 0, 10%);
overflow: hidden;
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/*대댓글 오른쪽으로 들어가게 하기*/
#commentContainer .comment.sub {
margin-left: 5rem;
}
/*댓글의 타이틀 모양*/
#commentContainer .comment > .head {
background-color: rgb(128, 139, 150);
color: rgb(255, 255, 255);
font-size: 0.9rem;
padding: 0.375rem 1rem;
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .comment.mine > .head {
background-color: rgb(40, 117, 225);
}
/*작성일시*/
#commentContainer .comment > .head > .dt {
color: rgb(213, 216, 220);
margin-left: 0.5rem;
}
/*답글달기, 삭제 오른쪽으로 보내기*/
#commentContainer .comment > .head > .spring {
flex: 1;
}
/*답글달기, 삭제*/
#commentContainer .comment > .head > .action-container {
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .comment > .head > .action-container > .action {
color: inherit;
text-decoration: none;
}
#commentContainer .comment > .head > .action-container > .action.cancel {
disply: none;
}
#commentContainer .comment.modifying > .head > .action-container > .action.cancel {
display: inline-block;
}
/*답글달기, 삭제 글자 사이 간격*/
#commentContainer .comment > .head > .action-container > .action + .action {
margin-left: 0.5rem;
}
#commentContainer .comment.modifying > .head > .action-container > .action.reply,
#commentContainer .comment.modifying > .head > .action-container > .action.modify,
#commentContainer .comment.modifying > .head > .action-container > .action.delete {
display: none;
}
/*답글달기, 삭제 글자 hover시 */
#commentContainer .comment > .head > .action-container > .action:hover {
text-decoration: underline;
}
/*댓글입력칸 공간 크기 조절*/
#commentContainer .comment > .body {
background-color: rgb(255, 255, 255);
font-size: 1rem;
padding: 0.75rem 1rem;
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
#commentContainer .comment > .body > .content {
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .comment.modifying > .body > .content {
display: none;
}
#commentContainer .comment > .body > .content > .text {
flex: 1;
}
#commentContainer .comment > .body > .content > .like {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
}
#commentContainer .comment > .body > .content > .like > .count {
color: rgb(128, 139, 150);
font-size: 0.8rem;
line-height: 100%;
}
#commentContainer .comment > .body > .modify-form {
align-items: stretch;
display: none;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .comment.modifying > .body > .modify-form {
display: flex;
}
#commentContainer .comment > .body > .modify-form > .label {
flex: 1;
margin-right: 0.5rem;
}
#commentContainer .comment > .body > .content > .like > .toggle {
width: 1.75rem;
height: 1.75rem;
border: 0.125rem solid rgb(171, 178, 185);
border-radius: 0.375rem;
color: rgb(171, 178, 185);
margin-bottom: 0.5rem;
text-decoration: none;
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
}
#commentContainer .comment > .body > .content > .like > .toggle:hover {
border: 0.125rem solid rgb(128, 139, 150);
color: rgb(128, 139, 150);
}
#commentContainer .comment.liked > .body > .content > .like > .toggle {
background-color: rgb(231, 76, 60);
border: 0.125rem solid rgb(241, 62, 22);
color: rgb(220, 213, 215);
}
#commentContainer .comment.liked > .body > .content > .like > .toggle:hover {
background-color: rgb(203, 67, 53);
border: 0.125rem solid rgb(203, 67, 53);
}
/*대댓글창 크기*/
#commentContainer .reply-form {
border-radius: 0.375rem;
box-shadow: 0 0 0.5rem 0.0625rem rgba(0, 0, 0, 10%);
overflow: hidden;
margin-left: 5rem;
align-items: stretch;
display: none;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .reply-form.visible {
display: inline-block;
}
/*대댓글창 머리부분*/
#commentContainer .reply-form > .head {
background-color: rgb(13, 77, 168);
color: rgb(255, 255, 255);
font-size: 0.9rem;
padding: 0.375rem 1rem;
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
}
#commentContainer .reply-form > .body {
background-color: rgb(255, 255, 255);
padding: 0.75rem 1rem;
align-items: stretch;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
#commentContainer .reply-form > .body > .label {
flex: 1;
margin-right: 0.5rem;
}
#commentContainer .reply-form > .head > .cancel {
color: inherit;
text-decoration: none;
}
#commentContainer .reply-form > .head > .cancel:hover {
text-decoration: underline;
}
#commentContainer .reply-form > .label {
flex: 1;
margin-right: 0.5rem;
}
read.js
따로 빼려면 복잡할 것 같아서
댓글달기, 수정, 삭제 로직이 함께 들어가 있다. (참고)
xhr을 open해주고 GET방식으로 /comment?aid=? 에 해당하는 게시글을 불러온다.
aid 값을 불러오는 이유는 사용자가 어느 게시글에 댓글을 달았는지 알아야 하기 때문이다. (article_index)
const commentForm = window.document.getElementById('commentForm');
const commentContainer = window.document.getElementById('commentContainer');
const loadComments = () => {
commentContainer.innerHTML = '';
const url = new URL(window.location.href);
const searchParams = url.searchParams;
const aid = searchParams.get('aid');
const xhr = new XMLHttpRequest();
xhr.open('GET', `./comment?aid=${aid}`);
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status < 300) {
const responseArray = JSON.parse(xhr.responseText);
const appendComment = (commentObject, isSub) => {
const commentHtmlText = `
<div class="comment ${isSub ? 'sub' : ''} ${commentObject['isMine'] === true ? 'mine' : ''} ${commentObject['isLiked']}" rel="comment">
<div class="head">
<span class="writer">${commentObject['userNickname']}</span>
<span class="dt">${commentObject['writtenOn']}</span>
<span class="spring"></span>
<span class="action-container">
${commentObject['isSigned'] === true ? '<a class="action reply" href="#" rel="actionReply">답글 달기</a>' : ''}
${commentObject['isMine'] === true ? '<a class="action modify" href="#" rel="actionModify">수정</a>' : ''}
${commentObject['isMine'] === true ? '<a class="action delete" href="#" rel="actionDelete">삭제</a>' : ''}
<a class="action cancel" href="#" rel="actionCancel">취소</a>
</span>
</div>
<div class="body">
<div class="content">
<span class="text">${commentObject['content']}</span>
<div class="like">
<a href="#" class="toggle">
<i class="icon fa-solid fa-heart"></i>
</a>
<span class="count">9,999</span>
</div>
</div>
<form class="modify-form" rel="modifyForm">
<label class="label">
<span hidden>댓글 수정</span>
<input class="--object-input" maxlength="100" name="content"
placeholder="댓글을 입력해 주세요" type="text">
</label>
<input class="--object-button" type="submit" value="수정">
</form>
</div>
</div>
<form class="reply-form" rel="replyForm">
<div class="head">
<span class="to">@${commentObject['userNickname']}</span>
<a href="#" class="cancel" rel="replyCancel">취소</a>
</div>
<div class="body">
<label class="label">
<span hidden>답글작성</span>
<input class="--object-input" maxlength="100" name="content"
placeholder="답글을 입력해 주세요" type="text">
</label>
<input class="--object-button" type="submit" value="작성">
</div>
</form>`;
// 위에서 가져온 의미 없는 문자열을 html 태그로 만들어버리기
const domParser = new DOMParser();
const dom = domParser.parseFromString(commentHtmlText, 'text/html'); // html 로 변환후 dom에 넣는다
const commentElement = dom.querySelector('[rel="comment"]'); // rel이 comment인 것
const replyFormElement = dom.querySelector('[rel="replyForm"]');
const modifyFormElement = dom.querySelector('[rel="modifyForm"]');
// 답글 달기 글자 클릭했을 때 visible 되면서 답글창이 나타남
dom.querySelector('[rel="actionReply"]')?.addEventListener('click', e => {
e.preventDefault();
replyFormElement.classList.add('visible');
replyFormElement['content'].focus();
});
// 답글창에서 취소 눌렀을 때 답글창이 사라지게됨
dom.querySelector('[rel="replyCancel"]')?.addEventListener('click', e => {
e.preventDefault();
replyFormElement.classList.remove('visible');
});
modifyFormElement.onsubmit = e => {
e.preventDefault();
// 댓글 수정 후 수정 버튼 눌렀을 때 알림창 띄우기
if (!confirm('댓글을 수정할까요?')) {
return;
}
// 댓글 수정 작업 요청
Cover.show('댓글 수정 중');
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('index', commentObject['index']);
formData.append('content', modifyFormElement['content'].value);
xhr.open('PATCH', './comment');
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
Cover.hide();
if (xhr.status >= 200 && xhr.status < 300) {
const responseObject = JSON.parse(xhr.responseText);
switch (responseObject['result']) {
case 'no_such_allowed':
alert('수정하려는 댓글이 더이상 존재하지 않습니다.');
break;
case 'not_allowed':
alert('댓글을 수정할 권한이 존재하지 않습니다.');
break;
case 'success':
loadComments();
break;
default:
alert('알수 없는 이유로 수정 못함');
}
} else {
alert('서버와 통신하지 못하였습니다');
}
}
};
xhr.send(formData);
}
// 댓글 삭제하기 눌렀을 때 댓글 삭제되게
dom.querySelector('[rel="actionDelete"]')?.addEventListener('click', e => {
e.preventDefault();
if (!confirm('정말로 댓글을 삭제 할건가요?')) {
return;
}
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('index', commentObject['index']);
xhr.open('DELETE', './comment');
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
Cover.hide();
if (xhr.status >= 200 && xhr.status < 300) {
const responseObject = JSON.parse(xhr.responseText);
switch (responseObject['result']) {
case 'success':
loadComments();
break;
case 'no_such_comment':
alert('삭제하려는 댓글이 더이상 존재하지 않습니다.\n이미 삭제되었을 수도 있어요.');
break;
case 'not_allowed':
alert('삭제할 권한이 없어요.');
break;
default:
alert('알 수 없는 이유로 삭제하지 못하였습니다. 다시 시도해주세요.');
}
} else {
alert('서버와 통신하지 못하였습니다. 잠시후 다시 시도해주세요');
}
}
};
xhr.send(formData);
});
// 수정하기 글자 눌렀을 때 밑에 댓글이 수정될 수 있게 바뀌고, 오른쪽에 답글달기, 수정, 삭제 글자 없어지게
dom.querySelector('[rel="actionModify"]')?.addEventListener('click', e => {
e.preventDefault();
commentElement.classList.add('modifying');
modifyFormElement['content'].value = commentObject['content'];
modifyFormElement['content'].focus();
});
// 취소 글씨를 눌렀을 때
dom.querySelector('[rel="actionCancel"]')?.addEventListener('click', e => {
e.preventDefault();
commentElement.classList.remove('modifying');
});
// 댓글 visible 된 상태에서 답글 내용 적고 작성버튼 눌렀을 때
// 댓글 작성을 위해 articleIndex, content, commentIndex 데이터 3개를 실어준다
replyFormElement.onsubmit = e => {
e.preventDefault();
if (replyFormElement['content'].value === '') {
replyFormElement['content'].focus();
return false;
}
Cover.show('댓글을 작성하고 있어요. 잠시만 기다려주세여.');
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('articleIndex', commentForm['aid'].value);
formData.append('content', replyFormElement['content'].value);
formData.append('commentIndex', commentObject['index']);
xhr.open('POST', './read')
xhr.onreadystatechange = () => {
Cover.hide();
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status < 300) {
const responseObject = JSON.parse(xhr.responseText);
switch (responseObject['result']) {
case 'success':
loadComments();
break;
default:
alert('알 수 없는 이유로 게시글을 작성하지 못하였습니다. 다시 시도해주세요');
}
} else {
alert('서버와 통신하지 못하였습니다. 잠시 후 다시 시도해 주세요');
}
}
}
xhr.send(formData);
};
commentContainer.append(commentElement, replyFormElement);
};
// for (let commentObject of responseArray.filter(x => !x['commentIndex'])) {
// const replyArray = responseArray.filter(x => x['commentIndex'] === commentObject['index']);
// appendComment(commentObject, false);
// for (let replyObject of replyArray) { // 대댓글 생성됨
// appendComment(replyObject, true);
// }
// }
//재귀호출
const appendReplyOf = parentComment => {
const replyArray = responseArray.filter(x => x['commentIndex'] === parentComment['index']);
// 만족하는 조건의 요소가 없을 경우 빈 배열이 나온다.
// => for문이 더이상 돌지 않고 재귀호출이 중단된다.
for (let replyObject of replyArray) {
appendComment(replyObject, true);
appendReplyOf(replyObject);
// 대댓글이 다시 부모댓글이 됨 (다시 함수 호출)
}
};
// filter로 commentIndex가 아닌 애를 걸러낸다
for (let commentObject of responseArray.filter(x => !x['commentIndex'])) {
appendComment(commentObject, false); // 원댓글 생성
appendReplyOf(commentObject);
}
}
} else {
}
}
xhr.send();
}
loadComments();
if (commentForm !== null) {
commentForm.onsubmit = e => {
e.preventDefault();
// 댓글 값이 비었다면 alert 창 띄움
if (commentForm['content'].value === '') {
alert('댓글을 입력해주세요.');
commentForm['content'].focus();
return false;
}
// 댓글 작성중
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('articleIndex', commentForm['aid'].value); // 게시글 번호 (14, 16)
formData.append('content', commentForm['content'].value); // 글 내용
xhr.open('POST', './read');
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
Cover.hide();
if (xhr.status >= 200 && xhr.status < 300) {
const responseObject = JSON.parse(xhr.responseText);
switch (responseObject['result']) {
case 'success':
loadComments();
break;
default:
alert('알 수 없는 이유로 댓글 작성을 못하였습니다.\n다시 시도해주세요.');
}
} else {
alert('서버와 통신하지 못했습니다.\n잠시 후 다시 시도해주세요');
}
}
};
xhr.send(formData);
}
}
개발자 도구
를 켜고 console창에 loadComments(); 입력을 하게 되면
insert된 댓글의 모든 정보가 Preview에 표시되어 나오게 된다.
실행결과
끝