springPage
WriterCheckInterceptor
생성package kr.spring.interceptor;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import kr.spring.board.service.BoardService;
import kr.spring.board.vo.BoardVO;
public class WriterCheckInterceptor extends HandlerInterceptorAdapter {
private static final Logger logger = LoggerFactory.getLogger(WriterCheckInterceptor.class);
@Autowired
private BoardService boardService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.info("<<로그인한 회원 번호와 작성자 회원 번호 일치 여부 검사>>");
// 작성자의 회원 번호 구하기
int board_num = Integer.parseInt(request.getParameter("board_num"));
BoardVO board = boardService.selectBoard(board_num);
// 로그인한 회원 번호 구하기
HttpSession session = request.getSession();
Integer user_num = (Integer)session.getAttribute("user_num");
// 로그인한 회원 번호와 작성자 회원 번호 일치 여부 검사
if(user_num==null || user_num!=board.getMem_num()) {
request.setAttribute("accessMsg", "로그인한 아이디와 작성자 아이디 불일치");
request.setAttribute("accessUrl", request.getContextPath() + "/board/list.do");
RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/common/notice.jsp");
dispatcher.forward(request, response);
return false;
}
return true;
}
}
servlet-context.xml
에서 <interceptors>
태그 사이에 다음의 내용을 추가 <interceptor>
<mapping path="/board/update.do"/>
<mapping path="/board/delete.do"/>
<beans:bean class="kr.spring.interceptor.WriterCheckInterceptor"/>
</interceptor>
src/main/webapp/resources
폴더 오른쪽 클릭하고 새 폴더 image_upload
생성ckeditor.js
, ckeditor.js.map
, uploadAdapter.js
, videoAdapter.js
를 js
폴더로 이동css
폴더의 style.css
하단에 다음의 내용을 추가/* 글 상세 */
.detail-img {
max-width: 500px;
}
div#reply_div {
padding: 5px 10px 40px 10px;
margin-top: 10px;
background-color: #eeeeee;
}
form#re_form {
width: 650px;
border: none;
}
span.re-title {
color: #000;
font-size: 12pt;
line-height: 200%;
}
span.letter-count {
font-size: 10pt;
color: #999;
}
textarea.rep-content {
width: 90%;
height: 50px;
margin: 10px;
}
div#re_first,div#mre_first {
float: left;
width: 70%;
padding-left: 15px;
margin-bottom: 10px;
}
div#re_second,div#mre_second {
float: left;
width: 19%;
margin-bottom: 10px;
}
div#loading {
width: 100px;
height: 50px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
}
div.paging-button {
text-align: right;
}
div#output {
clear: both;
}
form#mre_form {
border: none;
margin: 5px;
}
/* CKEditor 사용시 등록, 수정 폼 */
form#register_form, form#update_form {
width: 700px;
}
ajax-loader.gif
를 images
폴더로 이동table.sql
하단에 다음의 내용을 추가/* 댓글 */
CREATE TABLE spboard_reply(
re_num NUMBER NOT NULL,
re_content VARCHAR2(900) NOT NULL,
re_date DATE DEFAULT SYSDATE NOT NULL,
re_mdate DATE,
re_ip VARCHAR2(40) NOT NULL,
board_num NUMBER NOT NULL,
mem_num NUMBER NOT NULL,
CONSTRAINT spboard_reply_pk PRIMARY KEY (re_num),
CONSTRAINT reply_spboard_fk1 FOREIGN KEY (board_num) REFERENCES spboard (board_num),
CONSTRAINT reply_spmember_fk2 FOREIGN KEY (mem_num) REFERENCES spmember (mem_num)
);
CREATE SEQUENCE spreply_seq;
js
폴더에 새 JavaScript 파일 board.reply.js
생성$(function() {
let currentPage;
let count;
let rowCount;
// 댓글 목록
function selectData(pageNum, board_num) {
currentPage = pageNum;
// 로딩 이미지 노출
$('#loading').show();
$.ajax({
url:'listReply.do',
type:'post',
data:{pageNum:pageNum, board_num:board_num},
dataType:'json',
cache:false,
timeout:30000,
success:function(param) {
// 로딩 이미지 감추기
$('#loading').hide();
count = param.count;
rowCount = param.rowCount;
if(pageNum==1) { // 처음 호출시에는 해당 ID의 div 내용물 제거
$('#output').empty();
}
// 댓글 목록 작업
$(param.list).each(function(index, item) {
let output = '<div class="item">';
output += '<h4>' + item.id + '</h4>';
output += '<div class="sub-item">';
output += '<p>' + item.re_content.replace(/\r\n/g, '<br>') + '</p>';
if(item.re_mdate) {
output += '<span class="modify-date">최근 수정일 : ' + item.re_mdate + '</span>';
}
else {
output += '<span class="modify-date">등록일 : ' + item.re_date + '</span>'
}
if(param.user_num==item.mem_num) {
output += ' <input type="button" data-num="' + item.re_num + '" value="수정" class="modify-btn">';
output += ' <input type="button" data-num="' + item.re_num + '" value="삭제" class="delete-btn">';
}
output += '<hr size="1" noshade>';
output += '</div>'
output += '</div>'
// 문서 객체에 추가
$('#output').append(output);
}); // end of each
// paging button 처리
if(currentPage>=Math.ceil(count/rowCount)) { // 다음 페이지가 없음
$('.paging-button').hide();
}
else { // 다음 페이지가 존재
$('.paging-button').show();
}
},
error:function() {
// 로딩 이미지 감추기
$('#loading').hide();
alert('네트워크 오류 발생!');
}
}); // end of ajax
} // end of selectData
// 다음 댓글 보기 버튼 클릭시 데이터 추가
$('.paging-button input').click(function() {
let pageNum = currentPage+1;
selectData(pageNum, $('#board_num').val());
})
// 댓글 등록
$('#re_form').submit(function(event) {
if($('#re_content').val().trim()=='') {
alert('내용을 입력하세요!');
$('#re_content').val('').focus();
return false;
}
// 폼에 입력한 데이터 반환
let data = $(this).serialize();
// 데이터 전송
$.ajax({
url:'writeReply.do',
type:'post',
data:data,
dataType:'json',
cache:false,
timeout:30000,
success:function(param) {
if(param.result=='logout') {
alert('로그인해야 작성할 수 있습니다!');
}
else if(param.result=='success') {
// 폼 초기화
initForm();
// 댓글 작성이 성공하면 새로 삽입한 댓글을 포함해서 첫 번째 페이지의 댓글을 다시 호출
selectData(1, $('#board_num').val());
}
else {
alert('댓글 등록시 오류 발생!');
}
},
error:function() {
alert('네트워크 오류 발생!');
}
}); // end of ajax
// 기본 이벤트 제거
event.preventDefault();
}); // end of submit
// 댓글 작성 폼 초기화
function initForm() {
$('textarea').val('');
$('#re_first .letter-count').text('300/300');
} // end of initForm
// textarea에 내용 입력시 글자 수 체크
$(document).on('keyup', 'textarea', function() {
// 남은 글자 수 구하기
let inputLength = $(this).val().length;
if(inputLength>300) { // 300자를 초과한 경우
$(this).val($(this).val().substring(0, 300));
}
else { // 300자 이하인 경우
let remain = 300 - inputLength;
remain += '/300';
if($(this).attr('id')=='re_content') { // 등록 폼인 경우
$('#re_first .letter-count').text(remain);
}
else { // 수정 폼인 경우
$('#mre_first .letter-count').text(remain);
}
}
}); // end of on
// 댓글 수정 버튼 클릭시 수정 폼 노출
$(document).on('click', '.modify-btn', function() {
// 댓글 글 번호
let re_num = $(this).attr('data-num');
// 댓글 내용
let content = $(this).parent().find('p').html().replace(/<br>/gi, '\r\n');
// 댓글 수정 폼 UI
let modifyUI = '<form id="mre_form">';
modifyUI += '<input type="hidden" name="re_num" id="mre_num" value="' + re_num + '">';
modifyUI += '<textarea rows="3" cols="50" name="re_content" id="mre_content" class="rep-content">' + content + '</textarea>';
modifyUI += '<div id="mre_first"><span class="letter-count">300/300</span></div>';
modifyUI += '<div id="mre_second" class="align-right">';
modifyUI += '<input type="submit" value="수정">';
modifyUI += '<input type="button" value="취소" class="re-reset">';
modifyUI += '</div>';
modifyUI += '<hr size="1" width="96%" noshade>';
modifyUI += '</form>';
// 이전에 이미 수정 중인 댓글이 있을 경우 수정 버튼을 클릭하면 숨겨둔 sub-item을 노출시키고 수정 폼을 초기화
initModifyForm();
// 지금 클릭해서 수정하려는 데이터는 감추기
$(this).parent().hide(); // 수정 버튼을 감싸고 있는 div
// 수정 폼을 수정하고자 하는 데이터가 있는 div에 노출
$(this).parents('.item').append(modifyUI);
// 입력한 글자 수 세팅
let inputLength = $('#mre_content').val().length;
let remain = 300 - inputLength;
remain += '/300';
// 문서 객체에 반영
$('#mre_first .letter-count').text(remain);
}); // end of on
// 수정 폼에서 취소 버튼 클릭시 수정 폼 초기화
$(document).on('click', '.re-reset', function() {
initModifyForm();
}); // end of on
// 댓글 수정 폼 초기화
function initModifyForm() {
$('.sub-item').show();
$('#mre_form').remove();
}
// 댓글 수정
$(document).on('submit', '#mre_form', function(event) {
if($('#mre_content').val().trim()=='') {
alert('내용을 입력하세요!');
$('#mre_content').val('').focus();
return false;
}
// 폼에 입력한 데이터 반환
let data = $(this).serialize();
// 데이터 전송
$.ajax({
url:'updateReply.do',
type:'post',
data:data,
dataType:'json',
cache:false,
timeout:30000,
success:function(param) {
if(param.result=='logout') {
alert('로그인해야 수정할 수 있습니다!');
}
else if(param.result=='success') {
// 수정 데이터 표시
$('#mre_form').parent().find('p').html($('#mre_content').val().replace(/</g, '<').replace(/>/g, '>').replace(/\r\n/g, '<br>').replace(/\r/g, '<br>').replace(/\n/g, '<br>'));
// 최근 수정일 처리
$('#mre_form').parent().find('.modify-date').text('최근 수정일 : 5초 미만');
// 수정 폼 초기화
initModifyForm();
}
else if(param.result=='wrongAccess') {
alert('다른 사람의 댓글은 수정할 수 없습니다!');
}
else {
alert('댓글 수정시 오류 발생!');
}
},
error:function() {
alert('네트워크 오류 발생!');
}
}); // end of ajax
// 기본 이벤트 제거
event.preventDefault();
}); // end of on
// 댓글 삭제
$(document).on('click', '.delete-btn', function() {
// 댓글 번호
let re_num = $(this).attr('data-num');
$.ajax({
url:'deleteReply.do',
type:'post',
data:{re_num:re_num},
dataType:'json',
cache:false,
timeout:30000,
success:function(param) {
if(param.result=='logout') {
alert('로그인해야 삭제할 수 있습니다!');
}
else if(param.result=='success') {
alert('삭제 완료!');
selectData(1, $('#board_num').val());
}
else if(param.result=='wrongAccess') {
alert('다른 사람의 댓글은 삭제할 수 없습니다!');
}
else {
alert('댓글 삭제시 오류 발생!');
}
},
error:function() {
alert('네트워크 오류 발생!');
}
}); // end of ajax
}); // end of on
// 초기 데이터(목록) 호출
selectData(1, $('#board_num').val());
});
CommonController
생성package kr.spring.main.controller;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class CommonController {
@RequestMapping("/common/imageUploader.do") // uploadAdapter.js와 통신하는 경로
@ResponseBody
public Map<String, Object> uploadImage(MultipartFile upload, HttpSession session, HttpServletRequest request, HttpServletResponse response) throws Exception {
// 업로드할 폴더 경로
String realFolder = session.getServletContext().getRealPath("/resources/image_upload");
// 업로드할 파일명
String org_filename = upload.getOriginalFilename();
String str_filename = System.currentTimeMillis() + org_filename; // 파일명 중복 방지
Integer user_num = (Integer)session.getAttribute("user_num");
String filepath = realFolder + "\\" + user_num + "\\" + str_filename;
File f = new File(filepath);
if(!f.exists()) {
f.mkdirs(); // mkdir()은 상위 경로가 없으면 에러, mkdirs()는 상위 경로가 없으면 경로 생성
}
upload.transferTo(f);
Map<String, Object> map = new HashMap<String, Object>();
map.put("uploaded", true);
map.put("url", request.getContextPath() + "/resources/image_upload/" + user_num + "/" + str_filename);
return map;
}
}
boardWrite.jsp
상단에 다음의 내용을 추가<!-- 부트스트랩 라이브러리 -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<script type="text/javascript" src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script type="text/javascript" src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
<style>
.ck-editor__editable_inline {
min-height: 250px;
}
</style>
<!-- CKEditor 라이브러리 -->
<script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/ckeditor.js"></script>
<script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/uploadAdapter.js"></script>
boardWrite.jsp
에서 <ul>
태그 사이의 내용을 다음처럼 수정 <li>
<b>내용</b>
</li>
<li>
<form:textarea path="content"/>
<form:errors path="content" cssClass="error-color"/>
<script>
function MyCustomUploadAdapterPlugin(editor) {
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
return new UploadAdapter(loader);
}
}
ClassicEditor
.create( document.querySelector( '#content' ),{
extraPlugins: [MyCustomUploadAdapterPlugin]
})
.then( editor => {
window.editor = editor;
} )
.catch( error => {
console.error( error );
} );
</script>
</li>
boardView.jsp
상단에 다음의 내용을 추가<script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/jquery-3.6.0.min.js"></script>
<script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/board.reply.js"></script>
<script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/videoAdapter.js"></script>
boardView.jsp
에서 <div class="page-main">
태그 사이에 다음의 내용을 추가 <hr size="1" width="100%" noshade>
<div id="reply_div">
<span class="re-title">댓글 달기</span>
<form id="re_form">
<input type="hidden" name="board_num" value="${board.board_num}" id="board_num">
<textarea rows="3" cols="50" name="re_content" id="re_content" class="rep_content" <c:if test="${empty user_num}">disabled</c:if>><c:if test="${empty user_num}">로그인해야 작성할 수 있습니다</c:if></textarea>
<c:if test="${!empty user_num}">
<div id="re_first">
<span class="letter-count">300/300</span>
</div>
<div id="re_second" class="align-right">
<input type="submit" value="전송">
</div>
</c:if>
</form>
</div>
<!-- 댓글 목록 출력 -->
<div id="output"></div>
<div class="paging-button" style="display: none;">
<input type="button" value="다음 댓글 보기">
</div>
<div id="loading" style="display: none;">
<img src="${pageContext.request.contextPath}/resources/images/ajax-loader.gif">
</div>
BoardReplyVO
생성package kr.spring.board.vo;
import kr.spring.util.DurationFromNow;
public class BoardReplyVO {
private int re_num;
private String re_content;
private String re_date;
private String re_mdate;
private String re_ip;
private int board_num; // 부모글 번호
private int mem_num; // 댓글 작성자 회원 번호
private String id; // 댓글 작성자 아이디
public int getRe_num() {
return re_num;
}
public void setRe_num(int re_num) {
this.re_num = re_num;
}
public String getRe_content() {
return re_content;
}
public void setRe_content(String re_content) {
this.re_content = re_content;
}
public String getRe_date() {
return re_date;
}
public void setRe_date(String re_date) {
this.re_date = DurationFromNow.getTimeDiffLabel(re_date);
}
public String getRe_mdate() {
return re_mdate;
}
public void setRe_mdate(String re_mdate) {
this.re_mdate = DurationFromNow.getTimeDiffLabel(re_mdate);
}
public String getRe_ip() {
return re_ip;
}
public void setRe_ip(String re_ip) {
this.re_ip = re_ip;
}
public int getBoard_num() {
return board_num;
}
public void setBoard_num(int board_num) {
this.board_num = board_num;
}
public int getMem_num() {
return mem_num;
}
public void setMem_num(int mem_num) {
this.mem_num = mem_num;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
@Override
public String toString() {
return "BoardReplyVO [re_num=" + re_num + ", re_content=" + re_content + ", re_date=" + re_date + ", re_mdate="
+ re_mdate + ", re_ip=" + re_ip + ", board_num=" + board_num + ", mem_num=" + mem_num + ", id=" + id
+ "]";
}
}
BoardMapper
인터페이스에 다음의 메서드를 추가 // 댓글
public List<BoardReplyVO> selectListReply(Map<String, Object> map);
@Select("SELECT COUNT(*) FROM spboard_reply JOIN spmember USING(mem_num) WHERE board_num=#{board_num}")
public int selectRowCountReply(Map<String, Object> map);
@Select("SELECT * FROM spboard_reply WHERE re_num=#{re_num}")
public BoardReplyVO selectReply(Integer re_num);
@Insert("INSERT INTO spboard_reply (re_num, re_content, re_ip, board_num, mem_num) "
+ "VALUES (spreply_seq.NEXTVAL, #{re_content}, #{re_ip}, #{board_num}, #{mem_num})")
public void insertReply(BoardReplyVO boardReply);
@Update("UPDATE spboard_reply SET re_content=#{re_content}, re_ip=#{re_ip}, re_mdate=SYSDATE WHERE re_num=#{re_num}")
public void updateReply(BoardReplyVO boardReply);
@Delete("DELETE FROM spboard_reply WHERE re_num=#{re_num}")
public void deleteReply(Integer re_num);
// 부모글 삭제시 댓글이 존재하면 부모글 삭제 전 댓글 삭제
@Delete("DELETE FROM spboard_reply WHERE board_num=#{board_num}")
public void deleteReplyByBoardNum(Integer board_num);
BoardService
인터페이스에 다음의 내용을 추가import kr.spring.board.vo.BoardReplyVO;
BoardService
인터페이스에 다음의 메서드를 추가 // 댓글
public List<BoardReplyVO> selectListReply(Map<String, Object> map);
public int selectRowCountReply(Map<String, Object> map);
public BoardReplyVO selectReply(Integer re_num);
public void insertReply(BoardReplyVO boardReply);
public void updateReply(BoardReplyVO boardReply);
public void deleteReply(Integer re_num);
BoardServiceImpl
클래스에 다음의 내용을 추가import kr.spring.board.vo.BoardReplyVO;
BoardServiceImpl
클래스에서 deleteBoard
메서드를 다음처럼 수정 @Override
public void deleteBoard(Integer board_num) {
// 댓글이 존재하면 댓글을 우선 삭제하고 부모글을 삭제
boardMapper.deleteReplyByBoardNum(board_num);
boardMapper.deleteBoard(board_num);
}
BoardServiceImpl
클래스에 다음의 메서드를 추가 @Override
public List<BoardReplyVO> selectListReply(Map<String, Object> map) {
return boardMapper.selectListReply(map);
}
@Override
public int selectRowCountReply(Map<String, Object> map) {
return boardMapper.selectRowCountReply(map);
}
@Override
public BoardReplyVO selectReply(Integer re_num) {
return boardMapper.selectReply(re_num);
}
@Override
public void insertReply(BoardReplyVO boardReply) {
boardMapper.insertReply(boardReply);
}
@Override
public void updateReply(BoardReplyVO boardReply) {
boardMapper.updateReply(boardReply);
}
@Override
public void deleteReply(Integer re_num) {
boardMapper.deleteReply(re_num);
}
kr.spring.util
패키지에 새 클래스 DurationFromNow
생성package kr.spring.util;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class DurationFromNow {
/**
* 현재부터 "yyyyMMddHHmmss" 포맷의 날짜 차이 레이블
* @param date1
* @return String
*/
public static String getTimeDiffLabel(String date1) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return getTimeDiffLabel(sdf.parse(date1), new Date());
} catch (ParseException e) {
return "-";
}
}
/**
* 현재부터 Date 포맷의 날짜 차이 레이블
* @param d1
* @return String
*/
public static String getTimeDiffLabel(Date d1) {
return getTimeDiffLabel(d1, new Date());
}
/**
* "yyyyMMddHHmmss" 포맷의 날짜 차이 레이블
* @param date1
* @param date2
* @return String
*/
public static String getTimeDiffLabel(String date1, String date2) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd- HH:mm:ss");
try {
return getTimeDiffLabel(sdf.parse(date1), sdf.parse(date2));
} catch (ParseException e) {
return "-";
}
}
/**
* java.util.Date 포맷의 날짜 차이 레이블
* @param d1
* @param d2
* @return String
*/
public static String getTimeDiffLabel(Date d1, Date d2) {
long diff = d2.getTime() - d1.getTime();
int sec = (int)(diff / 1000);
if (sec < 5) return "5초미만";
if (sec < 60) return sec + "초 전";
int min = (int)(sec / 60);
if (min < 60) return min + "분 전";
int hour = (int)(min / 60);
if (hour < 24) return hour + "시간 전";
Calendar c1 = Calendar.getInstance();
Calendar c2 = (Calendar) c1.clone();
c1.setTime(d1);
c2.setTime(d2);
int day = c2.get(Calendar.DATE) - c1.get(Calendar.DATE);
if (day <= 0) {
day = hour / 24;
}
if (hour/24 < 30) {
if (day == 1) return "어제";
if (day == 2) return "2일전";
return day + "일전";
}
int month = hour / 24 / 30;
if (month == 1) return "한 달전";
if (month == 2) return "두 달전";
if (month < 12) return month + "달전";
int year = month / 12;
if (year == 1) return "작년";
return year + "년전";
}
}