String[] whiteList = {"/", "/user/loginform", "/user/login-fail",
"/user/expired", "/user/signup-form", "/user/signup",
"/test/**", "/user/check-id", "/board/list", "/board/view",
"/upload/**"};
<body>
<div class="container pt-3">
</div>
</body>
~{ } 붙이기 (빨간색 오류 표시 없앨 수 있다)<th:block th:insert="~{/include/resource :: bootstrap}"></th:block>
disabled 클래스를 추가할지 말지 결정
th:classappend
<h1>게시글 상세보기</h1>
<div class="btn-group mb-2">
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.prevNum})}">
<i class="bi bi-arrow-left"></i>
Prev
</a>
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.nextNum})}">
Next
<i class="bi bi-arrow-right"></i>
</a>
</div>
th:utext="${}"[(${})]<div class="card mt-4">
<div class="card-header bg-light">
<strong>본문 내용</strong>
</div>
<div class="card-body p-1">
[(${dto.content})]
</div>
</div>
dto : BoardDto TypeuserName : String Type<div th:if="${dto.writer eq userName}" class="text-end pt-2">
<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}>">Edit</a>
<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}>">Delete</a>
</div>
<div class="card my-3">
<div class="card-header bg-primary-subtle text-white">
댓글을 입력해 주세요
</div>
<div class="card-body">
<!-- 원글의 댓글을 작성할 폼 -->
<form th:action="@{/board/save-comment}" method="post">
<!-- 숨겨진 입력값 -->
<input type="hidden" name="parentNum" th:value="${dto.num}" />
<input type="hidden" name="targetWriter" th:value="${dto.writer}" />
<div class="mb-3">
<label for="commentContent" class="form-label">댓글 내용</label>
<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
</div>
<button type="submit" class="btn bg-primary-subtle text-white">등록</button>
</form>
</div>
</div>
view.html 에 게시글 상세보기
<h1>게시글 상세보기</h1>
<div class="btn-group mb-2">
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.prevNum})}">
<i class="bi bi-arrow-left"></i>
Prev
</a>
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.nextNum})}">
Next
<i class="bi bi-arrow-right"></i>
</a>
</div>
<table class="table table-striped">
<colgroup>
<col class="col-2" />
<col class="col" />
</colgroup>
<tr>
<th>글번호</th>
<td th:text="${dto.num}"></td>
</tr>
<tr>
<th>작성자</th>
<td>
<i th:if="${dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
<img th:unless="${dto.profileImage == null}"
th:src="@{/upload/{name}(name=${dto.profileImage})}"
style="width:100px;height:100px;border-radius:50%;" />
[[${dto.writer}]]
</td>
</tr>
<tr>
<th>제목</th>
<td th:text="${dto.title}"></td>
</tr>
<tr>
<th>조회수</th>
<td th:text="${dto.viewCount}"></td>
</tr>
<tr>
<th>작성일</th>
<td th:text="${dto.createdAt}"></td>
</tr>
</table>
<!--
클라이언트가 작성한 글 제목이나 내용을 그대로 클라이언트에게 출력하는 것은
javascript 주입 공격을 받을 수 있다
따라서 해당 문자열을 escape 해서 출력하는 것이 안전하다
-->
<div class="card mt-4">
<div class="card-header bg-light">
<strong>본문 내용</strong>
</div>
<div class="card-body p-1">
[(${dto.content})]
</div>
</div>
<div th:if="${dto.writer eq userName}" class="text-end pt-2">
<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}">Edit</a>
<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}">Delete</a>
</div>
<div class="card my-3">
<div class="card-header bg-primary-subtle text-white">
댓글을 입력해 주세요
</div>
<div class="card-body">
<!-- 원글의 댓글을 작성할 폼 -->
<form th:action="@{/board/save-comment}" method="post">
<!-- 숨겨진 입력값 -->
<input type="hidden" name="parentNum" th:value="${dto.num}" />
<input type="hidden" name="targetWriter" th:value="${dto.writer}" />
<div class="mb-3">
<label for="commentContent" class="form-label">댓글 내용</label>
<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
</div>
<button type="submit" class="btn bg-primary-subtle text-white">등록</button>
</form>
</div>
</div>
<div th:each="tmp : ${commentList}" class="card mb-3"
th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">
속성 추가
th:attr="속성명=${속성값}"
<button th:if="${tmp.writer eq userName}"
th:attr="data-num=${tmp.num}"
class="btn-close position-absolute top-0 end-0 m-1"></button>
<th:block th:if="${tmp.writer eq userName}">
<!-- 수정 버튼 (본인에게만 보임)-->
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
<!-- 댓글 수정 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/comment-update}" method="post">
<!-- 댓글을 수정하기 위한 댓글의 번호, 이 페이지로 다시 돌아오기 위한 parentNum 도 같이 전송되도록 -->
<input type="hidden" name="num" th:value="${dto.num}" />
<input type="hidden" name="parentNum" th:value=${tmp.num} />
<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content)"></textarea>
<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
</form>
</div>
</th:block>
<th:block th:unless="${tmp.writer eq userName}">
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
<!-- 댓글 입력 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/save-comment}" method="post">
<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야 한다 -->
<input type="hidden" name="parentNum" th:value="${dto.num}" />
<input type="hidden" name="targetWriter" th:value="${tmp.writer}" />
<input type="hidden" name="groupNum" th:value="${tmp.groupNum}" />
<textarea name="content" class="form-control mb-2" rows="2" placeholder="댓글을 입력하세요"></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
</form>
</div>
</th:block>
view.html 에 댓글 목록 출력
<div class="comments">
<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다, 그런 경우 왼쪽 마진을 부여 -->
<div th:each="tmp : ${commentList}" class="card mb-3"
th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">
<!-- 삭제된 댓글 -->
<div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">
삭제된 댓글입니다
</div>
<!-- 삭제되지 않은 댓글 -->
<div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
<button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
style="bottom:16px; right:16px;">
<i class="bi bi-caret-down"></i>
답글 [[${tmp.replyCount}]] 개
</button>
<i th:if="${tmp.num ne tmp.groupNum}" class="bi bi-arrow-return-right position-absolute"
style="top:0;left:-30px"></i>
<!-- 댓글 작성자가 로그인된 userName과 같을 때만 삭제 버튼 출력 -->
<button th:if="${tmp.writer eq userName}"
th:attr="data-num=${tmp.num}"
class="btn-close position-absolute top-0 end-0 m-1"></button>
<i th:if="${tmp.profileImage == null}"
style="font-size:50px;" class="bi bi-person-circle me-3 align-self-center"></i>
<img th:unless="${tmp.profileImage eq null}"
class="rounded-circle me-3 align-self-center"
th:src="@{/upload/{name}(name=${tmp.profileImage})}"
alt="프로필 이미지"
style="width:50px; height:50px;">
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<div>
<strong th:text="${tmp.writer}"></strong>
<span>@[[${tmp.targetWriter}]]</span>
<small class="text-muted" th:text="${tmp.createdAt}"></small>
</div>
</div>
<pre th:text="${tmp.content}"></pre>
<!-- 댓글 작성자가 로그인된 userName과 같으면 수정폼, 다르면 댓글폼 출력 -->
<th:block th:if="${tmp.writer eq userName}">
<!-- 수정 버튼 (본인에게만 보임) -->
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
<!-- 댓글 수정 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/comment-update}" method="post">
<!-- 댓글 번호, 부모 글 번호 전송 -->
<input type="hidden" name="num" th:value="${tmp.num}" />
<input type="hidden" name="parentNum" th:value="${dto.num}" />
<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content}"></textarea>
<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
</form>
</div>
</th:block>
<th:block th:unless="${tmp.writer eq userName}">
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
<!-- 댓글 입력 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/save-comment}" method="post">
<!-- 원글 번호, 댓글 대상자, 그룹 번호 전송 -->
<input type="hidden" name="parentNum" th:value="${dto.num}" />
<input type="hidden" name="targetWriter" th:value="${tmp.writer}" />
<input type="hidden" name="groupNum" th:value="${tmp.groupNum}" />
<textarea name="content" class="form-control mb-2" rows="2" placeholder="댓글을 입력하세요"></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
</form>
</div>
</th:block>
</div>
</div> <!-- card body -->
</div> <!-- card -->
</div>
원글의 글 번호가 1인 경우
"/spring08/user/loginform?url=/spring08/board/view?num=1";
location.href= "[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
/spring08/board/comment-delete?num=${num}&parentNum=1location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
// 클라이언트가 로그인 했는지 여부
const isLogin = [[${isLogin}]];
document.querySelector("#commentContent").addEventListener("input", () => {
// 원글의 댓글 입력란에 입력했을 때, 만일 로그인 하지 않았다면
if (!isLogin) {
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href =
"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
}
});
// 모든 댓글 버튼에 이벤트 등록
document.querySelectorAll(".show-reply-btn").forEach(item => {
// 매개변수에 전달된 item은 댓글 button의 참조값이다
item.addEventListener("click", () => {
// 댓글 버튼을 눌렀을 때, 만일 로그인 하지 않았다면
if (!isLogin) {
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href =
"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
return;
}
// 클릭한 버튼의 다음 형제요소의 class 목록에서 d-none 제거
item.nextElementSibling.classList.remove("d-none");
// 클릭한 버튼의 class 목록에 d-none 추가
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-reply-btn").forEach(item => {
item.addEventListener("click", () => {
// 가장 가까운 부모 요소 중에 클래스 속성이 form-div인 요소
const formDiv = item.closest(".form-div");
// formDiv에 d-none 클래스 추가해서 안 보이게 하고
formDiv.classList.add("d-none");
// formDiv의 이전 형제 요소(댓글버튼)에 d-none 클래스 제거해서 보이게 한다
formDiv.previousElementSibling.classList.remove("d-none");
});
});
// 클래스명이 edit-btn인 모든 버튼에 "click" 이벤트 리스너 등록
document.querySelectorAll(".edit-btn").forEach(item => {
item.addEventListener("click", () => {
// 클릭한 버튼의 다음 형제 요소의 class 목록에서 d-none 제거
item.nextElementSibling.classList.remove("d-none");
// 클릭한 버튼의 class 목록에 d-none 추가
item.classList.add("d-none");
});
});
// 취소 버튼을 눌렀을 때 이벤트 리스너 등록
document.querySelectorAll(".cancel-edit-btn").forEach(item => {
item.addEventListener("click", () => {
// 가장 가까운 부모 요소 중에 클래스 속성이 form-div인 요소
const formDiv = item.closest(".form-div");
// formDiv에 d-none 클래스 추가해서 안 보이게 하고
formDiv.classList.add("d-none");
// formDiv의 이전 형제 요소(댓글 버튼)에 d-none 클래스 제거해서 보이게 한다
formDiv.previousElementSibling.classList.remove("d-none");
});
});
// 삭제 버튼을 눌렀을 때
document.querySelectorAll(".btn-close").forEach(item => {
item.addEventListener("click", () => {
// data-num 속성에 출력된 삭제할 댓글의 번호
const num = item.getAttribute("data-num");
const isDelete = confirm(num + "번 댓글을 삭제하시겠습니까?");
// confirm은 확인:true, 취소:false
if (isDelete) {
location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
}
});
});
document.querySelectorAll(".dropdown-btn").forEach(item => {
item.addEventListener("click", (e) => {
// click 이벤트가 발생한 그 버튼의 자손 요소 중에서 caret up 또는 caret down 요소를 찾는다
const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
// caret 모양을 위 아래로 토글 시킨다
caret.classList.toggle("bi-caret-down");
caret.classList.toggle("bi-caret-up");
// 1. 버튼(item)의 두 단계 부모 요소로 이동
const grandParent = item.parentElement.parentElement;
// 2. 두 단계 부모 요소의 바로 다음 형제 요소의 참조값을 얻어낸다
let next = grandParent.nextElementSibling;
// 3. 반복문 돌면서 (다음 형제 요소가 있는 동안에 반복문 돌기)
while (next) {
// 만일 re-re 클래스가 존재한다면
if (next.classList.contains("re-re")) {
// d-block 클래스를 토글시켜서 보였다 숨겼다를 반복시킨다
next.classList.toggle("d-block");
} else {
// 존재하지 않으면 반복문 탈출
break;
}
// 다음 형제 요소의 참조값 얻어내기
next = next.nextElementSibling;
}
});
});
view.html 전체 코드
<style>
/* 대댓글이 처음에는 보이지 않도록 하기 위해*/
.re-re{
display:none;
}
</style>
<body>
<div class="container pt-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a th:href="@{/}">Home</a>
</li>
<li class="breadcrumb-item">
<a th:href="@{/board/list}">Board</a>
</li>
<li class="breadcrumb-item active">Detail</li>
</ol>
</nav>
<h1>게시글 상세보기</h1>
<div class="btn-group mb-2">
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.prevNum})}">
<i class="bi bi-arrow-left"></i>
Prev
</a>
<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
th:href="@{/board/view(num=${dto.nextNum})}">
Next
<i class="bi bi-arrow-right"></i>
</a>
</div>
<table class="table table-striped">
<colgroup>
<col class="col-2" />
<col class="col" />
</colgroup>
<tr>
<th>글번호</th>
<td th:text="${dto.num}"></td>
</tr>
<tr>
<th>작성자</th>
<td>
<i th:if="${dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
<img th:unless="${dto.profileImage == null}"
th:src="@{/upload/{name}(name=${dto.profileImage})}"
style="width:100px;height:100px;border-radius:50%;"/>
[[${dto.writer}]]
</td>
</tr>
<tr>
<th>제목</th>
<td th:text="${dto.title}"></td>
</tr>
<tr>
<th>조회수</th>
<td th:text="${dto.viewCount}"></td>
</tr>
<tr>
<th>작성일</th>
<td th:text="${dto.createdAt}"></td>
</tr>
</table>
<!--
클라이언트가 작성한 글 제목이나 내용을 그대로 클라이언트에게 출력하는 것은
javascript 주입 공격을 받을 수 있다
따라서 해당 문자열을 escape 해서 출력하는 것이 안전하다
-->
<div class="card mt-4">
<div class="card-header bg-light">
<strong>본문 내용</strong>
</div>
<div class="card-body p-1">
[(${dto.content})]
</div>
</div>
<div th:if="${dto.writer eq userName}" class="text-end pt-2">
<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}">Edit</a>
<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}">Delete</a>
</div>
<div class="card my-3">
<div class="card-header bg-primary-subtle text-white">
댓글을 입력해 주세요
</div>
<div class="card-body">
<!-- 원글의 댓글을 작성할 폼 -->
<form th:action="@{/board/save-comment}" method="post">
<!-- 숨겨진 입력값 -->
<input type="hidden" name="parentNum" th:value="${dto.num}"/>
<input type="hidden" name="targetWriter" th:value="${dto.writer}"/>
<div class="mb-3">
<label for="commentContent" class="form-label">댓글 내용</label>
<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
</div>
<button type="submit" class="btn bg-primary-subtle text-white">등록</button>
</form>
</div>
</div>
<!-- 댓글 목록 출력 -->
<div class="comments">
<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다, 그런 경우 왼쪽 마진을 부여 -->
<div th:each="tmp : ${commentList}" class="card mb-3"
th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">
<!-- 삭제된 댓글 -->
<div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">삭제된 댓글입니다</div>
<!-- 삭제되지 않은 댓글 -->
<div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
<button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
style="bottom:16px; right:16px;">
<i class="bi bi-caret-down"></i>
답글 [[${tmp.replyCount}]] 개
</button>
<i th:if="${tmp.num ne tmp.groupNum}"
class="bi bi-arrow-return-right position-absolute" style="top:0;left:-30px"></i>
<!-- 앳글 작성자가 로그인된 userName 과 같을 때만 삭제 버튼 출력 -->
<button th:if="${tmp.writer eq userName}"
th:attr="data-num=${tmp.num}"
class="btn-close position-absolute top-0 end-0 m-1"></button>
<i th:if="${tmp.profileImage == null}"
style="font-size:50px;" class="bi bi-person-circle me-3 align-self-center"></i>
<img th:unless="${tmp.profileImage eq null}"
class="rounded-circle me-3 align-self-center"
th:src="@{/upload/{name}(name=${tmp.profileImage})}"
alt="프로필 이미지"
style="width:50px; height:50px;">
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<div>
<strong th:text="${tmp.writer}"></strong>
<span>@[[${tmp.targetWriter}]]</span>
<small class="text-muted" th:text="${tmp.createdAt}"></small>
</div>
</div>
<pre th:text="${tmp.content}"></pre>
<!-- 댓글 작성작가 로그인된 userName 과 같으면 수정폼, 다르면 댓글폼을 출력한다 -->
<th:block th:if="${tmp.writer eq userName}">
<!-- 수정 버튼 (본인에게만 보임)-->
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
<!-- 댓글 수정 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/comment-update}" method="post">
<!-- 댓글을 수정하기 위한 댓글의 번호, 이 페이지로 다시 돌아오기 위한 parentNum 도 같이 전송되도록 -->
<input type="hidden" name="num" th:value="${tmp.num}"/>
<input type="hidden" name="parentNum" th:value="${dto.num}"/>
<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content}"></textarea>
<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
</form>
</div>
</th:block>
<th:block th:unless="${tmp.writer eq userName}">
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
<!-- 댓글 입력 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/board/save-comment}" method="post">
<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야 한다 -->
<input type="hidden" name="parentNum" th:value="${dto.num}"/>
<input type="hidden" name="targetWriter" th:value="${tmp.writer}"/>
<input type="hidden" name="groupNum" th:value="${tmp.groupNum}"/>
<textarea name="content" class="form-control mb-2" rows="2"
placeholder="댓글을 입력하세요"></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
</form>
</div>
</th:block>
</div>
</div> <!-- card body -->
</div> <!-- card -->
</div>
</div> <!-- container -->
<script>
// 클라이언트가 로그인 했는지 여부
const isLogin = [[${isLogin}]];
document.querySelector("#commentContent").addEventListener("input", ()=>{
//원글의 댓글 입력란에 입력했을때 만일 로그인 하지 않았다면
if(!isLogin){
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href=
"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
}
});
// 모든 댓글 버튼에 이벤트 등록
document.querySelectorAll(".show-reply-btn").forEach(item=>{
// 매개변수에 전달된 item 은 댓글 button 의 참조값이다
item.addEventListener("click", ()=>{
//댓글 버튼을 눌렀을때 만일 로그인 하지 않았다면
if(!isLogin){
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href=
"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
return;
}
// 클릭한 버튼의 다음 형제요소의 class 목록에서 d-none 을 제거
item.nextElementSibling.classList.remove("d-none");
// 클릭한 버튼의 class 목록에 d-none 을 추가
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-reply-btn").forEach(item=>{
item.addEventListener("click", ()=>{
// 가장 가까운 부모 요소중에 클래스 속성이 form-div 인요소
const formDiv=item.closest(".form-div");
// formDiv 에 d-none 클래스 추가해서 안보이게 하고
formDiv.classList.add("d-none");
// formDiv 의 이전 형제요소(댓글버튼)에 d-none 클래스 제거해서 보이게 한다
formDiv.previousElementSibling.classList.remove("d-none");
});
});
// 클래스명이 edit-btn 인 모든 버튼에 "click" 이벤트 리스너 등록
document.querySelectorAll(".edit-btn").forEach(item=>{
item.addEventListener("click", ()=>{
// 클릭한 버튼의 다음 형제 요소의 class 목록에서 d-none 을 제거
item.nextElementSibling.classList.remove("d-none");
// 클릭한 버튼의 class 목록에 d-none 을 추가
item.classList.add("d-none");
});
});
// 취소 버튼을 눌렀을 때 이벤트 리스너 등록
document.querySelectorAll(".cancel-edit-btn").forEach(item=>{
item.addEventListener("click", ()=>{
// 가장 가까운 부모 요소 중에 클래스 속성이 form-div 인 요소
const formDiv = item.closest(".form-div");
// formDiv 에 d-none 클래스르 추가해서 안 보이게 하고
formDiv.classList.add("d-none");
// formDiv 의 이전 형제 요소(댓글 버튼)에 d-none 클래스 제거해서 보이게 한다
formDiv.previousElementSibling.classList.remove("d-none");
});
});
// 삭제 버튼을 눌렀을 때
document.querySelectorAll(".btn-close").forEach(item=>{
item.addEventListener("click", ()=>{
// data-num 속성에 출력된 삭제할 댓글의 번호
const num = item.getAttribute("data-num");
const isDelete = confirm(num+"번 댓글을 삭제하시겠습니까?");
// confirm 은 확인:true 취소:false
if(isDelete){
location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
}
});
});
document.querySelectorAll(".dropdown-btn").forEach(item => {
item.addEventListener("click", (e) => {
// click 이벤트가 발생한 그 버튼의 자손요소 중에서 caret up 또는 caret down 요소를 찾는다
const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
// caret 모양을 위 아래로 토글 시킨다
caret.classList.toggle("bi-caret-down");
caret.classList.toggle("bi-caret-up");
// 1. 버튼(item)의 두 단계 부모 요소로 이동
const grandParent = item.parentElement.parentElement;
// 2. 두 단계 부모 요소의 바로 다음 형제 요소의 참조값을 얻어낸다
let next = grandParent.nextElementSibling;
// 3. 반복문 돌면서 (다음 형제 요소가 있는 동안에 반복문 돌기)
while (next) {
// 만일 re-re 클래스가 존재한다면
if (next.classList.contains("re-re")) {
// d-block 클래스를 토글시켜서 보였다 숨겼다를 반복 시킨다
next.classList.toggle("d-block");
}else{ // 존재하지 않으면
// 반복문 탈출
break;
}
// 다음 형제 요소의 참조값 얻어내기
next = next.nextElementSibling;
}
});
});
</script>
</body>
insert & getSequence 문 추가<insert id="insert" parameterType="commentDto">
INSERT INTO comments
(num, writer, targetWriter, content, parentNum, groupNum)
VALUES(#{num}, #{writer}, #{targetWriter}, #{content}, #{parentNum}, #{groupNum})
</insert>
<select id="getSequence" resultType="int">
SELECT comments_seq.NEXTVAL AS num FROM DUAL
</select>
insert 메소드 생성public void insert(CommentDto dto);
@Override
public void insert(CommentDto dto) {
session.insert("comment.insert", dto);
}
createComment 메소드 생성public void updateComment(CommentDto dto); //댓글 수정
@Override
public void createComment(CommentDto dto) {
// 댓글의 글번호가 넘어오지 않으면 dto.getGroupNum() 은 0 을 리턴한다
// 저장할 댓글의 pk 를 미리 얻어낸다
int num = commentDao.getSequence();
dto.setNum(num);
// 만일 원글의 댓글이면
if(dto.getGroupNum() == 0) {
dto.setGroupNum(num); // 원글의 댓글은 자신의 글번호가 댓글의 그룹번호이고
}
// 댓글 작성자를 얻어내서 dto 에 담는다
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
dto.setWriter(userName);
// 대댓글이면 이미 dto 에 댓글의 그룹번호가 들어 있다
commentDao.insert(dto);
}
boardSaveComment 메소드 생성@PostMapping("/board/save-comment")
public String boardSaveComment(CommentDto dto) {
// 서비스를 이용해서 새로운 댓글을 저장
service.createComment(dto);
// 댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동
return "redirect:/board/view?num="+dto.getParentNum();
}
delete 문 추가<update id="delete" parameterType="int">
UPDATE comments
SET deleted='yes'
WHERE num=#{num}
</update>
delete 메소드 생성public int delete(int num);
@Override
public int delete(int num) {
return session.update("comment.delete", num);
}
deleteComment 메소드 생성public void deleteComment(int num); // 댓글 삭제
boardDeleteComment 메소드 생성@GetMapping("/board/comment-delete")
public String boardDelete(CommentDto dto) {
// dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다
service.deleteComment(dto.getNum());
return "redirect:/board/view?num="+dto.getParentNum();
}
getByNum 메소듯 생성public CommentDto getByNum(int num);
@Override
public CommentDto getByNum(int num) {
return session.selectOne("comment.getByNum", num);
}
getByNum 문 추가<select id="getByNum" parameterType="int" resultType="commentDto">
SELECT num, writer, targetWriter, content, parentNum, groupNum, createdAt
FROM comments
WHERE num=#{num}
</select>
@Override
public void deleteComment(int num) {
// 글 작성자와 로그인된 userName 이 동일한지 비교해서 동일하지 않으면 예외를 발생시킨다
String writer = commentDao.getByNum(num).getWriter();
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if(!writer.equals(userName)) {
throw new RuntimeException("남의 글을 지울 수 없습니다");
}
// 글 삭제하기
commentDao.delete(num);
}
update 메소드 생성<update id="update" parameterType="commentDto">
UPDATE comments
SET content=#{content}
WHERE num=#{num}
</update>
update 메소드 생성public int update(CommentDto dto);
@Override
public int update(CommentDto dto) {
return session.update("comment.update", dto);
}
updateComment 메소드 생성public void updateComment(CommentDto dto); //댓글 수정
boardUpdateComment 메소드 생성@PostMapping("/board/comment-update")
public String boardUpdateComment(CommentDto dto) {
service.updateComment(dto);
return "redirect:/board/view?num="+dto.getParentNum();
}
@Override
public void updateComment(CommentDto dto) {
// 글 작성자와 로그인된 userName 이 동일한지 비ㅛ해서 동일하지 않으면 예외를 발생시킨다
String writer = commentDao.getByNum(dto.getNum()).getWriter();
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if(!writer.equals(userName)) {
throw new RuntimeException("남의 글을 수정할 수 없습니다");
}
commentDao.update(dto);
}
select & option 코드 추가<select name="search" class="form-select">
<option value="title_content">제목+내용</option>
<option value="title">제목</option>
<option value="writer">작성자</option>
</select>
BoardDto 에 search 필드 추가
검색 조건을 담기 위한 필드
private String search;
getBoardList 메소드의 파라미터 수정public BoardListResponse getBoardList(int pageNum, BoardDto dto);
public List<BoardDto> selectPage(BoardDto dto);
public int getCount(BoardDto dto);
@Override
public List<BoardDto> selectPage(BoardDto dto) {
return session.selectList("board.selectPage", dto);
}
@Override
public int getCount(BoardDto dto) {
// resultType : int
return session.selectOne("board.getCount", dto);
}
⭐ selectPage 메소드에 dto
keyword 가 있을 수도 있고 없을 수도 있다
keyword 가 있다면 search 는 'title_content' or 'title' or 'writer'
selectPage 문 수정option value 값 where절 X<select id="selectPage" parameterType="boardDto" resultType="boardDto">
SELECT *
FROM
(SELECT result1.*, ROWNUM AS rnum
FROM
(SELECT num, writer, title, viewCount, createdAt
FROM board
<choose>
<when test="search == 'writer'">
WHERE writer LIKE '%' || #{keyword} || '%'
</when>
<when test="search == 'title'">
WHERE title LIKE '%' || #{keyword} || '%'
</when>
<when test="search == 'title_content'">
WHERE title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%'
</when>
</choose>
ORDER BY num DESC
) result1)
WHERE rnum BETWEEN #{startRowNum} AND #{endRowNum}
</select>
<select id="getCount" parameterType="boardDto" resultType="int">
SELECT NVL(MAX(ROWNUM), 0) AS count
FROM board
<choose>
<when test="search == 'writer'">
WHERE writer LIKE '%' || #{keyword} || '%'
</when>
<when test="search == 'title'">
WHERE title LIKE '%' || #{keyword} || '%'
</when>
<when test="search == 'title_content'">
WHERE title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%'
</when>
</choose>
</select>
search & query 필드 추가private String keyword; // 검색 키워드
private String search; // 검색 조건
private String query; // GET 방식 파라미터 keyword "search=xxx&keyword=xxx" or ""
@Override
public BoardListResponse getBoardList(int pageNum, BoardDto dto) {
//한 페이지에 몇개씩 표시할 것인지
final int PAGE_ROW_COUNT=3;
//하단 페이지를 몇개씩 표시할 것인지
final int PAGE_DISPLAY_COUNT=3;
//보여줄 페이지의 시작 ROWNUM
int startRowNum=1+(pageNum-1)*PAGE_ROW_COUNT; //공차수열
//보여줄 페이지의 끝 ROWNUM
int endRowNum=pageNum*PAGE_ROW_COUNT; //등비수열
//하단 시작 페이지 번호 (정수를 정수로 나누면 소수점이 버려진 정수가 나온다)
int startPageNum = 1 + ((pageNum-1)/PAGE_DISPLAY_COUNT)*PAGE_DISPLAY_COUNT;
//하단 끝 페이지 번호
int endPageNum=startPageNum+PAGE_DISPLAY_COUNT-1;
// 전체글의 갯수
int totalRow = boardDao.getCount(dto);
// 전체 페이지의 갯수 구하기
int totalPageCount=(int)Math.ceil(totalRow/(double)PAGE_ROW_COUNT);
// 끝 페이지 번호가 이미 전체 페이지 갯수보다 크게 계산되었다면 잘못된 값이다
if(endPageNum > totalPageCount){
endPageNum=totalPageCount; //보정해 준다
}
// startRowNum 과 endRowNum 을 BoardDto 객체에 담아서
dto.setStartRowNum(startRowNum);
dto.setEndRowNum(endRowNum);
// 글 목록 얻어오기 (검색 키워드가 있다면 조건에 맞는 목록만 얻어낸다)
List<BoardDto> list = boardDao.selectPage(dto);
// query 문자열을 미리 구성해서 view page 에 전달하도록 한다
String query = "";
if(dto.getKeyword() != null) {
query = "search="+dto.getSearch()+"&keyword="+dto.getKeyword();
}
// 한줄 coding 으로 BoardListResponse 객체를 만들어서 리턴
return BoardListResponse.builder()
.list(list)
.pageNum(pageNum)
.keyword(dto.getKeyword())
.search(dto.getSearch())
.query(query)
.startPageNum(startPageNum)
.endPageNum(endPageNum)
.totalPageCount(totalPageCount)
.totalRow(totalRow)
.build();
}
list 메소드 수정@GetMapping("/board/list")
public String list(Model model,
@RequestParam(defaultValue = "1") int pageNum,
BoardDto dto) {
// BoardDto 객체에는 keyword 와 search 가 있을 수도 있다 (없을 수도 있다 null)
// 응답에 필요한 데이터를 얻어내서
BoardListResponse listResponse = service.getBoardList(pageNum, dto);
// 모델 객체에 담고
model.addAttribute("dto", listResponse);
// 타임리프 페이지에서 응답
return "board/list";
}
list.html select 코드 수정 및 코드 추가
th:selected
<select name="search" class="form-select">
<option th:selected="${dto.search eq 'title_content'}" value="title_content">제목+내용</option>
<option th:selected="${dto.search eq 'title'}" value="title">제목</option>
<option th:selected="${dto.search eq 'writer'}" value="writer">작성자</option>
</select>
<p th:if="${dto.keyword ne null}">
<strong th:text="${dto.keyword}"></strong> 에 대한 검색 결과
<strong th:text="${dto.totalRow}"></strong> 개
</p>