@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class GalleryUploadRequest {
private String title;
private String content;
// <input type="file" name="images" multiple>
// images 라는 파라미터명으로 파일이 여러개 전송되니까 MultipartFile[] type 으로 선언한다
private MultipartFile[] images;
}
createGallery 메소드 생성public void createGallery(GalleryUploadRequest galleryRequest);
작업 중에 DataAccessException type 의 예외가 발생하면 자동 rollback
@Repository 어노테이션이 붙은 dao 에서 DB 관련 작업중에
SQLException 이 발생하면 spring 이 해당 예외를 잡아서 @DataAccessException 을 자동으로 발생시킨다
(transaction 에 영향을 주기 위해서)
@Mapper 를 이용해서 dao 를 만들면 해당 dao 에 자동으로 @Repository 어노테이션이 붙는다
서비스에서 어떤 동작을 하다가 에러가 난 경우 transaction 에 영향을 주고 싶으면 일반 예외를 발생시키지 말고 DataAccessException 을 throw 하면 transaction 관리가 된다
커스텀 Exception 을 발생시켜서 transaction 을 관리하고 싶으면 커스텀 Exception 클래스를 만들 때 반드시 RuntimeException 클래스 말고 DataAccessException 클래스를 상속받아서 만들고 확정 조건 하에서 해당 Exception 를 발생시키면 자동으로 transaction 관리가 된다
// 업로드된 이미지를 저장할 위치 얻어내기
@Value("${file.location}")
private String fileLocation;
// 이 서비스에서 일어나는 DB 관련 작업을 하나의 transaction 단위로 묶기
@Transactional
@Override
public void createGallery(GalleryUploadRequest galleryRequest) {
// 이 Gallery 의 pk 를 미리 얻어낸다 (이미지 정보를 DB 에 저장할 때 galleryNum 으로 사용된다)
int num = galleryMapper.getSequence();
// 로그인된 userName
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
// Gallery 정보도 DB 에 저장한다
GalleryDto dto = GalleryDto.builder()
.num(num)
.title(galleryRequest.getTitle())
.content(galleryRequest.getContent())
.writer(userName)
.build();
galleryMapper.insert(dto);
// 업로드된 이미지 파일의 정보를 가지고 있는 배열
MultipartFile[] images = galleryRequest.getImages();
// 반복문 돌면서 배열에 저장된 MultipartFile 객체를 순서대로 참조하면서 이미지 관련 처리를 한다
for(int i=0; i<images.length; i++) {
// 배열에서 원하는 인덱스에 해당하는 MultipartFile 객체를 참조
MultipartFile image = images[i];
// 원본 파일명
String orgFileName = image.getOriginalFilename();
// 이미지의 확장자를 유지하기 위해 뒤에 원본 파일명을 추가
String saveFileName = UUID.randomUUID().toString() + orgFileName;
// 저장할 파일의 전체 경로 구성하기
String filePath = fileLocation + File.separator + saveFileName;
try {
// 업로드된 파일을 저장할 파일 객체 생성
File saveFile = new File(filePath);
image.transferTo(saveFile);
} catch(Exception e) {
e.printStackTrace();
}
// GalleryImageDto 객체에 이미지 하나의 정보를 담고
GalleryImageDto imageDto = GalleryImageDto.builder()
.galleryNum(num)
.saveFileName(saveFileName)
.build();
// DB 에 저장
galleryMapper.insertImage(imageDto);
}
}
⭐ gallery 테이블의 num 을
gallery_image 테이블의 galleryNum 칼럼에서 Reference 하기 때문에
GalleryDto 를 먼저 DB 에 저장해야 한다
@PostMapping("/gallery/save")
public String gallerySave(GalleryUploadRequest uploadRequest, RedirectAttributes ra) {
// 업로드된 파일의 모든 정보를 GalleryUploadRequest 의 images 라는 MultipartFile[] 객체에 담아서 전달
galleryService.createGallery(uploadRequest);
// 리다일렉트 된 페이지에서 전달한 메세지
ra.addFlashAttribute("message", "Gallery 정보를 성공적으로 저장 완료");
return "redirect:/gallery/list";
}
galleryView 메소드 생성@GetMapping("/gallery/view")
public String galleryView(int num) {
return "gallery/view";
}
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class GalleryViewResponse {
private String userName;
private boolean isLogin;
private GalleryDto dto;
private List<GalleryImageDto> images;
private List<CommentDto> commentList;
}
➜ 해당 구조로 표시된다
{
"userName" : "kim",
"isLogin" : true,
"dto" : { },
"list" : [{}, {}, {}, ...],
"commentList" : [{}, {}, {}, ...]
}
getGallery 메소드 생성public GalleryViewResponse getGallery(int num);
private final CommentDao commentDao;
@Override
public GalleryViewResponse getGallery(int num) {
// 로그인된 userName 을 얻어낸 다음 로그인을 안 했으면 "anonymousUser" 이다
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
// 로그인 여부
boolean isLogin = userName.equals("anonymousUser") ? false : true;
// GalleryDto 얻어내기
GalleryDto dto = galleryMapper.getData(num);
// dto 의 content 에서 개행기호를 <br> 요소로 변경한 다음 다시 넣기
String result = dto.getContent().replace("\r\n", "<br>").replace("\n", "<br>");
dto.setContent(result);
// 이미지 목록
List<GalleryImageDto> images = galleryMapper.getImageList(num);
// 댓글 목록
List<CommentDto> commentList = commentDao.selectList(num);
return GalleryViewResponse.builder()
.userName(userName)
.isLogin(isLogin)
.dto(dto)
.images(images)
.commentList(commentList)
.build();
}
@GetMapping("/gallery/view")
public String galleryView(int num, Model model) {
GalleryViewResponse response = galleryService.getGallery(num);
model.addAttribute("res", response);
return "gallery/view";
}
<style>
/* 대댓글이 처음에는 보이지 않도록 하기 위해*/
.re-re{
display:none;
}
</style>
<div class="container">
<div class="card shadow">
<!-- 작성자 정보 -->
<div class="card-header d-flex align-items-center">
<i th:if="${res.dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
<img th:unless="${res.dto.profileImage == null}"
th:src="@{/upload/{name}(name=${res.dto.profileImage})}"
style="width:100px;height:100px;border-radius:50%;"/>
<strong class="ms-3" th:text="${res.dto.writer}"></strong>
<span class="text-muted ms-auto" th:text="${res.dto.createdAt}"></span>
</div>
<!-- 본문 -->
<div class="card-body">
<h5 class="card-title" th:text="${res.dto.title}"></h5>
<!-- textarea 로 입력한 글에서 개행기호는 br 요소로 변경해서 출력하기 -->
<p class="card-text">[(${res.dto.content})]</p>
<div class="row">
<div th:each="tmp : ${res.images}" class="col-md-6 mb-4">
<img th:src="@{/upload/{name}(name=${tmp.saveFileName})}"
class="img-fluid rounded" alt="photo">
</div>
</div>
</div>
</div> <!-- .card -->
<div class="card my-3">
<div class="card-header bg-primary text-white">
댓글을 입력해 주세요
</div>
<div class="card-body">
<!-- 원글의 댓글을 작성할 폼 -->
<form th:action="@{/gallery/save-comment}" method="post">
<!-- 숨겨진 입력값 -->
<input type="hidden" name="parentNum" th:value="${res.dto.num}"/>
<input type="hidden" name="targetWriter" th:value="${res.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 btn-success">등록</button>
</form>
</div>
</div>
<!-- 댓글 목록을 출력하기 -->
<div class="comments">
<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다. 그런경우 왼쪽 마진을 부여한다 -->
<div th:each="tmp : ${res.commentList}"
class="card mb-3"
th:classappend="${tmp.num eq 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>
<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 eq 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>
<th:block th:if="${tmp.writer eq res.userName}">
<!-- 수정 버튼 (본인에게만 보임) -->
<button class="btn btn-sm btn-outline-secondary edit-btn">수정</button>
<!-- 댓글 수정 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/gallery/comment-update}" method="post">
<!-- 댓글을 수정하기 위한 댓글의 번호, 이페지이로 다시 돌아오기위한 parentNum 도 같이 전송되도록 -->
<input type="hidden" name="num" th:value="${tmp.num}"/>
<input type="hidden" name="parentNum" th:value="${res.dto.num}"/>
<textarea name="content" class="form-control mb-2" rows="2" >[[${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 res.userName}">
<button class="btn btn-sm btn-outline-primary show-reply-btn">댓글</button>
<!-- 댓글 입력 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{/gallery/save-comment}" method="post">
<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야한다 -->
<input type="hidden" name="parentNum" th:value="${res.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-success">등록</button>
<button type="reset" class="btn btn-sm btn-secondary cancel-reply-btn">취소</button>
</form>
</div>
</th:block>
</div>
</div> <!-- .card-body -->
</div><!-- .card -->
</div> <!-- .comments -->
</div><!-- .container -->
<script>
//클라이언트가 로그인 했는지 여부
const isLogin = [[${res.isLogin}]];
//대댓글 보기 버튼을 눌렀을때 실행할 함수 등록
document.querySelectorAll(".dropdown-btn").forEach(item => {
item.addEventListener("click", (e) => {
const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
caret.classList.toggle("bi-caret-down");
caret.classList.toggle("bi-caret-up");
const grandParent = item.parentElement.parentElement;
let next = grandParent.nextElementSibling;
while (next) {
if (next.classList.contains("re-re")) {
next.classList.toggle("d-block");
} else {
break;
}
next = next.nextElementSibling;
}
});
});
document.querySelectorAll(".btn-close").forEach(item => {
item.addEventListener("click", ()=>{
const num=item.getAttribute("data-num");
const isDelete=confirm(num+" 번 댓글을 삭제 하시겠습니까?");
if(isDelete){
location.href=`[[@{/gallery/comment-delete}]]?num=${num}&parentNum=[[${res.dto.num}]]`;
}
});
});
document.querySelectorAll(".edit-btn").forEach(item => {
item.addEventListener("click", ()=>{
item.nextElementSibling.classList.remove("d-none");
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-edit-btn").forEach(item=>{
item.addEventListener("click", ()=>{
const formDiv=item.closest(".form-div");
formDiv.classList.add("d-none");
formDiv.previousElementSibling.classList.remove("d-none");
});
});
document.querySelector("#commentContent").addEventListener("input", ()=>{
if(!isLogin){
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href=
"[[@{/user/loginform}]]?url=[[@{/gallery/view(num=${res.dto.num})}]]";
}
});
//모든 댓글 버튼에 이벤트 등록
document.querySelectorAll(".show-reply-btn").forEach(item=>{
item.addEventListener("click", ()=>{
if(!isLogin){
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href=
"[[@{/user/loginform}]]?url=[[@{/gallery/view(num=${res.dto.num})}]]";
return;
}
item.nextElementSibling.classList.remove("d-none");
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-reply-btn").forEach(item=>{
item.addEventListener("click", ()=>{
const formDiv=item.closest(".form-div");
formDiv.classList.add("d-none");
formDiv.previousElementSibling.classList.remove("d-none");
});
});
</script>
include 폴더에 comment.html 파일 생성
로그인 여부 isLogin
로그인 되었다면 로그인된 userName
<th:block th:fragment="commentUi(category, parentNum, parentWriter, list)">
<div class="card my-3">
<div class="card-header bg-primary-subtle text-white">
댓글을 입력해 주세요
</div>
<div class="card-body">
<!-- 원글의 댓글을 작성할 폼 -->
<form th:action="@{|/${category}/save-comment|}" method="post">
<!-- 숨겨진 입력값 -->
<input type="hidden" name="parentNum" th:value="${parentNum}" />
<input type="hidden" name="targetWriter" th:value="${parentWriter}" />
<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 : ${list}" class="card mb-3"
th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re d-none'}">
<!-- 삭제된 댓글 -->
<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 은 #authentication.name 으로 참조하면 된다
로그인이 안되어 있으면 "anonymousUser" 로 참조된다
*/-->
<button th:if="${tmp.writer eq #authentication.name}" 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 #authentication.name}">
<!-- 수정 버튼 (본인에게만 보임)-->
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
<!-- 댓글 수정 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{|/${category}/comment-update|}" method="post">
<input type="hidden" name="num" th:value="${tmp.num}" />
<input type="hidden" name="parentNum" th:value="${parentNum}" />
<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 #authentication.name}">
<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
<!-- 댓글 입력 폼 (처음에는 숨김) -->
<div class="d-none form-div">
<form th:action="@{|/${category}/save-comment|}" method="post">
<input type="hidden" name="parentNum" th:value="${parentNum}" />
<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>
</div>
</div>
<script>
// 클라이언트가 로그인 했는지 여부
const isLogin = [[${#authentication.name == 'anonymousUser' ? false: true}]];
document.querySelector("#commentContent").addEventListener("input", () => {
if (!isLogin) {
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href = "[[@{/user/loginform}]]?url=[[@{|/${category}/view?num=${parentNum}|}]]";
}
});
document.querySelectorAll(".show-reply-btn").forEach(item => {
item.addEventListener("click", () => {
if (!isLogin) {
alert("댓글 작성을 위해 로그인이 필요합니다!");
location.href = "[[@{/user/loginform}]]?url=[[@{|/${category}/view?num=${parentNum}|}]]";
return;
}
item.nextElementSibling.classList.remove("d-none");
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-reply-btn").forEach(item => {
item.addEventListener("click", () => {
const formDiv = item.closest(".form-div");
formDiv.classList.add("d-none");
formDiv.previousElementSibling.classList.remove("d-none");
});
});
document.querySelectorAll(".edit-btn").forEach(item => {
item.addEventListener("click", () => {
item.nextElementSibling.classList.remove("d-none");
item.classList.add("d-none");
});
});
document.querySelectorAll(".cancel-edit-btn").forEach(item => {
item.addEventListener("click", () => {
const formDiv = item.closest(".form-div");
formDiv.classList.add("d-none");
formDiv.previousElementSibling.classList.remove("d-none");
});
});
document.querySelectorAll(".btn-close").forEach(item => {
item.addEventListener("click", () => {
const num = item.getAttribute("data-num");
const isDelete = confirm(num + "번 댓글을 삭제하시겠습니까?");
if (isDelete) {
location.href = `[[@{|/${category}/comment-delete|}]]?num=${num}&parentNum=[[${parentNum}]]`;
}
});
});
document.querySelectorAll(".dropdown-btn").forEach(item => {
item.addEventListener("click", (e) => {
const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
caret.classList.toggle("bi-caret-down");
caret.classList.toggle("bi-caret-up");
const grandParent = item.parentElement.parentElement;
let next = grandParent.nextElementSibling;
while (next) {
if (next.classList.contains("re-re")) {
next.classList.toggle("d-none");
} else {
break;
}
next = next.nextElementSibling;
}
});
});
</script>
</th:block>
@RequiredArgsConstructor
@Controller
public class CommentController {
private final CommentService service;
@PostMapping("/{category}/comment-update")
public String commentUpdate(CommentDto dto,
@PathVariable("category") String category) {
service.updateComment(dto);
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@GetMapping("/{category}/comment-delete")
public String boardDelete(CommentDto dto,
@PathVariable("category") String category) {
//dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다.
service.deleteComment(dto.getNum());
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@PostMapping("/{category}/save-comment")
public String boardSave(CommentDto dto,
@PathVariable("category") String category) {
//서비스를 이용해서 새로운 댓글을 저장한다
service.createComment(dto);
//댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동시키기
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
}
public interface CommentService {
public List<CommentDto> getComments(int parentNum);
public void createComment(CommentDto dto); // 댓글 저장
public void updateComment(CommentDto dto); //댓글 수정
public void deleteComment(int num); // 댓글 삭제
}
@RequiredArgsConstructor
@Service
public class CommentServiceImpl implements CommentService{
private final CommentDao commentDao;
@Override
public List<CommentDto> getComments(int parentNum) {
return commentDao.selectList(parentNum);
}
@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);
}
@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);
}
@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);
}
}
@PostMapping("/{category}/comment-update")
public String boardUpdateComment(CommentDto dto,
@PathVariable String category) {
service.updateComment(dto);
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@GetMapping("/{category}/comment-delete")
public String boardDeleteComment(CommentDto dto,
@PathVariable String category) {
// dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다
service.deleteComment(dto.getNum());
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@PostMapping("/{category}/save-comment")
public String boardSaveComment(CommentDto dto,
@PathVariable String category) {
// 서비스를 이용해서 새로운 댓글을 저장
service.createComment(dto);
// 댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
CommentController 클래스 생성
@RequiredArgsConstructor
@Controller
public class CommentController {
}
commentService 의존성 주입 & boardView 메소드 수정private final CommentService commentService;
List<CommentDto> comments = commentService.getComments(requestDto.getNum());
@RequiredArgsConstructor
@Controller
public class CommentController {
private final CommentService service;
@PostMapping("/{category}/comment-update")
public String commentUpdate(CommentDto dto,
@PathVariable("category") String category) {
service.updateComment(dto);
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@GetMapping("/{category}/comment-delete")
public String boardDelete(CommentDto dto,
@PathVariable("category") String category) {
//dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다.
service.deleteComment(dto.getNum());
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
@PostMapping("/{category}/save-comment")
public String boardSave(CommentDto dto,
@PathVariable("category") String category) {
//서비스를 이용해서 새로운 댓글을 저장한다
service.createComment(dto);
//댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동시키기
return "redirect:/"+category+"/view?num="+dto.getParentNum();
}
}
<th:block th:insert="/include/navbar :: nav('view')"></th:block>
<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>
<!--/*
th:if="${message != null}"
th:if="${!#strings.isEmpty(message)}"
대신에
th:if="${message}" 로 사용하면 편리하다
만일 message 가 존재한다면 이라고 읽으면 된다
*/-->
<p th:if="${message}" class="alert alert-success p-3" th:text="${message}"></p>
<!--/*
Thymeleaf view page 에서 요청 파라미터로 전달되었던 값도 추출할 수 있다
${param.파라미터명}
${#strings } 는 문자열에 관련된 유틸리티 객체를 활용할 수 있다
${!#strings.isEmpty(param.search)} 는 search 라는 파라미터명으로 전달된 값이 있는지 확인
*/-->
<p th:if="${!#strings.isEmpty(param.search)}"
class="alert alert-info px-3 py-2 rounded-3 shadow-sm">
<i class="bi bi-search me-2"></i>
<strong th:text="${param.search}"></strong> 조건
<strong th:text="${param.keyword}"></strong> 검색 결과 입니다
</p>
<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}${query}|}">
<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}${query}|}">
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 #authentication.name}" 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>
<th:block th:insert="~{/include/comment :: commentUi('board', ${dto.num}, ${dto.writer}, ${commentList})}"></th:block>
</div> <!-- container -->
<th:block th:insert="/include/navbar :: nav('view')"></th:block>
<div class="container pt-3">
<div class="card shadow">
<!-- 작성자 정보 -->
<div class="card-header d-flex align-items-center">
<i th:if="${res.dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>
<img th:unless="${res.dto.profileImage == null}"
th:src="@{/upload/{name}(name=${res.dto.profileImage})}"
style="width:100px;height:100px;border-radius:50%;" />
<strong class="ms-3">[[${res.dto.writer}]]</strong>
<span class="text-muted ms-auto">[[${res.dto.createdAt}]]</span>
</div>
<!-- 본문 -->
<div class="card-body">
<h5 class="card-title">[[${res.dto.title}]]</h5>
<p class="card-text">[(${res.dto.content})]</p>
<div class="row">
<div th:each="tmp : ${res.images}" class="col-md-6 mb-4">
<img th:src="@{/upload/{name}(name=${tmp.saveFileName})}"
class="img-fluid rounded" alt="photo">
</div>
</div>
</div>
</div>
<!--/*
commentUi('카테고리명' , ${원글의 글 번호}, ${원글의 작성자}, ${댓글 목록}
을 전달하면 댓글 관련 UI 가 여기에 랜더링
*/-->
<th:block th:insert="~{/include/comment :: commentUi('gallery', ${res.dto.num}, ${res.dto.writer}, ${res.commentList})}"></th:block>
</div><!-- .container -->
<h2>공지사항</h2>
<ul>
<li th:each="tmp : ${notice}" th:text="${tmp}"></li>
</ul>
<p>
userName : <strong>[[${#authentication.name}]]</strong>
</p>
<p>
userName : <strong sec:authentication="name"></strong>
</p>
<p>
userName : <strong th:text="${#authentication.name}"></strong>
</p>
<!--/* 로그인 했을 때만 특정 요소를 렌더링 하는 방법*/-->
<p sec:authorize="isAuthenticated()">로그인 중</p>
<p th:if="${#authentication.name != 'anoymousUser'}">로그인 중</p>
<!--/* 로그인을 하지 않았을 특정 요소를 렌더링 하는 방법*/-->
<p sec:authorize="!isAuthenticated()">로그인 안 한 상태</p>
<p th:if="${#authentication.name == 'anoymousUser'}">로그인 중</p>