영화 번호를 누르면 조회페이지로 이동합니다. 서비스 계층에서 영화 번호를 이용하여 MovieDTO를 가져오는 작업부터 진행하겠습니다.
MovieService
MovieDTO getMovie(Long mno);
MovieServiceImpl
@Override
public MovieDTO getMovie(Long mno) {
List<Object[]> result = movieRepository.getMovieWithAll(mno);
Movie movie = (Movie) result.get(0)[0];
List<MovieImage> movieImageList = new ArrayList<>();
result.forEach(arr -> {
MovieImage movieImage = (MovieImage) arr[1];
movieImageList.add(movieImage);
});
Double avg = (Double) result.get(0)[2];
Long reviewCnt = (Long) result.get(0)[3];
return entitiesToDTO(movie, movieImageList, avg, reviewCnt);
}
Controller부분도 구현합니다. 조회 페이지와 수정 페이지의 양식은 동일하기 때문에 Get방식으로 호출하고 수정 페이지에서 Post부분만 새로 작성하면 됩니다.
MovieController
@GetMapping({"/read", "/modify"})
public void read(long mno, @ModelAttribute("requestDTO") PageRequestDTO requestDTO, Model model) {
log.info("mno: " + mno);
MovieDTO movieDTO = movieService.getMovie(mno);
model.addAttribute("dto", movieDTO);
}
조회 페이지는 이전과 비슷한 형태로 구성합니다. 가장 아래에는 사진을 볼 수 있고, Review Count
버튼을 누르면 리뷰가 보이게 됩니다.(뒤에 리뷰 페이지에서 세부 구현)
read.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content})}">
<th:block th:fragment="content">
<h1 class="mt-4">Movie Read Page</h1>
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" name="title" th:value="${dto.title}" readonly>
</div>
<div class="form-group">
<label>Review Count</label>
<input type="text" class="form-control" name="reviewCnt" th:value="${dto.reviewCnt}" readonly>
</div>
<div class="form-group">
<label>Avg</label>
<input type="text" class="form-control" name="avg" th:value="${dto.avg}" readonly>
</div>
<style>
.uploadResult {
width: 100%;
background-color: gray;
margin-top: 10px;
}
.uploadResult ul {
display: flex;
flex-flow: row;
justify-content: center;
align-items: center;
vertical-align: top;
overflow: auto;
}
.uploadResult ul li {
list-style: none;
padding: 10px;
margin-left: 2em;
}
.uploadResult ul li img {
width: 100px;
}
</style>
<div class="uploadResult">
<ul>
<li th:each="movieImage: ${dto.imageDTOList}">
<img th:if="${movieImage.path != null}" th:src="|/display?fileName=${movieImage.getThumbnailURL()}|">
</li>
</ul>
</div>
<button type="button" class="btn btn-primary">
Review Count <span class="badge badge-light">[[${dto.reviewCnt}]]</span>
</button>
<script>
$(document).ready(function(e) {
});
</script>
</th:block>
</th:block>
목록 아래에 리뷰를 추가하게 된다면 서버에 DTO를 통해 전달해야 합니다. ReviewDTO부터 생성하겠습니다.
ReviewDTO
package org.zerock.mreview.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewDTO {
private Long reviewnum;
private Long mno;
private Long id;
private String nickname;
private String email;
private int grade;
private String text;
private LocalDateTime regDate, modDate;
}
또한 리뷰를 수정할 수 있도록 Review 엔티티에서 평점과 리뷰를 수정할 수 있는 setter를 구현합니다.
Review
//추가
public void changeGrade(int grade) {
this.grade = grade;
}
public void changeText(String text) {
this.text = text;
}
다음으로는 서비스 계층을 생성하겠습니다. 서비스 계층에서는 리뷰 목록을 가져오는 메서드와, 리뷰 등록, 수정, 삭제 기능을 구현합니다.
ReviewService
package org.zerock.mreview.service;
import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.entity.Member;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.Review;
import java.util.List;
public interface ReviewService {
List<ReviewDTO> getListOfMovie(Long mno);
Long register(ReviewDTO reviewDTO);
void modify(ReviewDTO movieReviewDTO);
void remove(Long reviewnum);
default Review dtoToEntity(ReviewDTO movieReviewDTO) {
Review movieReview = Review.builder()
.reviewnum(movieReviewDTO.getReviewnum())
.movie(Movie.builder().mno(movieReviewDTO.getMno()).build())
.text(movieReviewDTO.getText())
.grade(movieReviewDTO.getGrade())
.member(Member.builder().mid(movieReviewDTO.getMid()).build())
.build();
return movieReview;
}
default ReviewDTO entityToDTO(Review movieReview) {
ReviewDTO movieReviewDTO = ReviewDTO.builder()
.reviewnum(movieReview.getReviewnum())
.mno(movieReview.getMovie().getMno())
.mid(movieReview.getMember().getMid())
.nickname(movieReview.getMember().getNickname())
.email(movieReview.getMember().getEmail())
.grade(movieReview.getGrade())
.text(movieReview.getText())
.regDate(movieReview.getRegDate())
.modDate(movieReview.getModDate())
.build();
return movieReviewDTO;
}
}
ReviewServiceImpl
package org.zerock.mreview.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.Review;
import org.zerock.mreview.repository.ReviewRepository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Log4j2
@RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService {
@Autowired
private final ReviewRepository reviewRepository;
@Override
public List<ReviewDTO> getListOfMovie(Long mno) {
Movie movie = Movie.builder().mno(mno).build();
List<Review> result = reviewRepository.findByMovie(movie);
return result.stream().map(movieReview -> entityToDTO(movieReview)).collect(Collectors.toList());
}
@Override
public Long register(ReviewDTO reviewDTO) {
Review movieReview = dtoToEntity(reviewDTO);
reviewRepository.save(movieReview);
return movieReview.getReviewnum();
}
@Override
public void modify(ReviewDTO movieReviewDTO) {
Optional<Review> result = reviewRepository.findById(movieReviewDTO.getReviewnum());
if(result.isPresent()) {
Review movieReview = result.get();
movieReview.changeGrade(movieReviewDTO.getGrade());
movieReview.changeText(movieReview.getText());
reviewRepository.save(movieReview);
}
}
@Override
public void remove(Long reviewnum) {
reviewRepository.deleteById(reviewnum);
}
}
많이 작성해본 CRUD 기능이므로 설명은 생략하겠습니다.
다음으론 각 영화의 상세 페이지에서 리뷰 버튼을 눌렀을 때 리뷰 목록과 추가, 수정, 삭제가 가능하도록 Controller를 작성하겠습니다.
ReviewController
package org.zerock.mreview.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.service.ReviewService;
import java.util.List;
@RestController
@Log4j2
@RequestMapping("/reviews")
@RequiredArgsConstructor
public class ReviewController {
private final ReviewService reviewService;
@GetMapping("/{mno}/all")
public ResponseEntity<List<ReviewDTO>> getList(@PathVariable("mno") Long mno) {
log.info("-------------list-------------");
log.info("MNO: " + mno);
List<ReviewDTO> reviewDTOList = reviewService.getListOfMovie(mno);
return new ResponseEntity<>(reviewDTOList, HttpStatus.OK);
}
@PostMapping("/{mno}/all")
public ResponseEntity<Long> getList(@RequestBody ReviewDTO movieReviewDTO) {
log.info("---------------list---------------");
log.info("reviewDTO: " + movieReviewDTO);
Long reviewNum = reviewService.register(movieReviewDTO);
return new ResponseEntity<>(reviewNum, HttpStatus.OK);
}
@PutMapping("/{mno}/{reviewnum}")
public ResponseEntity<Long> modifyReview(@PathVariable Long reviewnum, @RequestBody ReviewDTO reviewDTO) {
log.info("----------------modify review-------------------");
log.info("reviewDTO: " + reviewDTO);
reviewService.modify(reviewDTO);
return new ResponseEntity<>(reviewnum, HttpStatus.OK);
}
@DeleteMapping("/{mno}/{reviewnum}")
public ResponseEntity<Long> removeReview(@PathVariable Long reviewnum) {
log.info("-------------remove review-------------------");
log.info("reviewnum: " + reviewnum);
reviewService.remove(reviewnum);
return new ResponseEntity<>(reviewnum, HttpStatus.OK);
}
}
서버의 작업은 완료하였고, 클라이언트 페이지를 작성하겠습니다.
read.html
<div class="reviewModal modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Movie Review</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Reviewer ID</label>
<input type="text" class="form-control" name="mid">
</div>
<div class="form-group">
<label>
Grade
<span class="grade"></span>
</label>
<div class="stars"></div>
</div>
<div class="form-group">
<label>Review Text</label>
<input type="text" class="form-control" name="text" placeholder="Good Movie!">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary reviewSaveBtn">Save Changes</button>
<button type="button" class="btn btn-warning modifyBtn">Modify</button>
<button type="button" class="btn btn-danger removeBtn">Remove</button>
</div>
</div>
</div>
</div>
<div class="imageModal modal" tabindex="-2" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Picture</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
Read 페이지에 모달을 2개 추가합니다. reviewModal
은 review 등록 등의 처리가 되었을 때 보여지는 모달창이고, imageModal
은 사진을 눌렀을 때 확대하여 보여주는 모달창입니다.
별점 처리는 starrr이라는 라이브러리를 사용합니다.
https://github.com/dobtco/starrr
위의 깃허브 링크에서 dist 폴더 내에있는 css와 js파일을 각각 static 아래 css와 js폴더로 복사합니다.
read 페이지에 starrr 라이브러리와 font-awesome 링크를 추가합니다.
read.html
...
<script th:src="@{/js/starrr.js}"></script>
<link th:href="@{/css/starrr.css}" rel="stylesheet">
<link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.2.0/css/font-awesome.min.css">
<script>
$(document).ready(function(e) {
var grade = 0;
var mno = [[${dto.mno}]];
$('.starrr').starrr({
rating: grade,
change: function(e, value) {
if(value) {
console.log(value);
grade = value;
}
}
});
$(".reviewModal").modal("show");
...
영화 조회 페이지로 들어가면 위와 같은 모달창이 보여지고, 원하는 별점을 클릭하면 콘솔창에 Grade가 표시됩니다.
리뷰 등록 버튼을 추가하고, 버튼을 눌렀을 때 모달창이 로딩되도록 작성합니다.
read.html
<button type="button" class="btn btn-primary">
Review Count <span class="badge badge-light">[[${dto.reviewCnt}]]</span>
</button>
//추가
<button type="button" class="btn btn-info addReviewBtn">
Review Register
</button>
<div class="list-group reviewList"></div>
...
//script에 추가
//$(".reviewModal").modal("show");
var reviewModal = $(".reviewModal");
var inputMid = $('input[name="mid"]');
var inputText = $('input[name="text"]');
$(".addReviewBtn").click(function() {
inputMid.val("");
inputText.val("");
$(".removeBtn, .modifyBtn").hide();
$(".reviewSaveBtn").show();
reviewModal.modal('show');
});
이 모달창은 숨겨져있다가 Register 버튼을 눌렀을 때 활성화되도록 스크립트를 추가합니다. 또한 모달창에서 save 버튼을 눌렀을 때 Controller의 POST로 전송되어 리뷰가 저장되도록 합니다.
read.html
$('.reviewSaveBtn').click(function() {
var data = {mno:mno, grade:grade, text:inputText.val(), mid:inputMid.val()};
console.log(data);
$.ajax({
url: '/reviews/'+mno,
type: "POST",
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "text",
success: function(result) {
console.log("result: " + result);
self.location.reload();
}
})
reviewModal.modal('hide');
});
리뷰가 추가되면 새로운 리스트로 다시 가져오도록 스크립트를 추가합니다.
function getMovieReviews() {
function formatTime(str) {
var date = new Date(str);
return date.getFullYear() + '/' +
(date.getMonth() + 1) + '/' +
date.getDate() + ' ' +
date.getHours() + ':' +
date.getMinutes();
}
$.getJSON("/reviews/" + mno + "/all", function(arr) {
var str = "";
$.each(arr, function(idx, review) {
console.log(review);
str += '<div class="card-body" data-reviewnum=' + review.reviewnum + ' data-mid=' + review.mid + '>';
str += '<h5 class="card-title">' + review.text + ' <span>' + review.grade + '</span></h5>';
str += '<h6 class="card-subtitle mb-2 text-muted">' + review.nickname + '</h6>';
str += '<p class="card-text">' + formatTime(review.regDate) + '</p>';
str += '</div>';
});
$(".reviewList").html(str);
});
}
getMovieReviews();
});
리뷰를 클릭하면 수정과 삭제를 할 수 있는 모달창이 생성되도록 코드를 추가합니다.
var reviewnum;
$(".reviewList").on("click", ".card-body", function() {
$(".reviewSaveBtn").hide();
$(".removeBtn, .modifyBtn").show();
var targetReview = $(this);
reviewnum = targetReview.data("reviewnum");
console.log("reviewnum: " + reviewnum);
inputMid.val(targetReview.data("mid"));
inputText.val(targetReview.find('.card-title').clone().children().remove().end().text());
var grade = targetReview.find('.card-title span').html();
$(".starrr a:nth-child("+grade+")").trigger('click');
$('.reviewModal').modal('show');
});
Modify버튼을 누르면 수정되고, Remove버튼을 누르면 삭제되도록 이벤트를 추가합니다.
$(".modifyBtn").on("click", function() {
var data = {reviewnum: reviewnum, mno:mno, grade:grade, text:inputText.val(), mid:inputMid.val()};
console.log(data);
$.ajax({
url:'/reviews/'+mno+"/"+reviewnum,
type:"PUT",
data:JSON.stringify(data),
contentType:"application/json; charset=utf-8",
dataType:"text",
success: function(result) {
console.log("result: " + result);
self.location.reload();
}
})
reviewModal.modal('hide');
});
$(".removeBtn").on("click", function() {
var data = {reviewnum: reviewnum};
console.log(data);
$.ajax({
url: '/reviews/'+mno+"/"+reviewnum,
type: "DELETE",
contentType: "application/json; charset=utf-8",
dataType:"text",
success: function(result) {
console.log("result: " + result);
self.location.reload();
}
})
removeModal.modal('hide');
});
마지막으로 이미지를 클릭했을 때 원본이미지를 모달창에서 보여주도록 설계합니다.
먼저 UploadController의 getFile()에서 매개변수 size를 추가하여, size = "1"일 때는 원본 이미지를 가져올 수 있도록 Controller를 수정합니다.
UploadController
@GetMapping("/display")
public ResponseEntity<byte[]> getFile(String fileName, String size) {
ResponseEntity<byte[]> result = null;
log.info(fileName);
try {
String srcFileName = URLDecoder.decode(fileName, "UTF-8");
log.info("fileName: " + srcFileName);
File file = new File(uploadPath + File.separator + srcFileName);
//추가
if( size != null && size.equals("1")) {
file = new File(file.getParent(), file.getName().substring(2));
}
log.info("file: " + file);
HttpHeaders header = new HttpHeaders();
header.add("Content-Type", Files.probeContentType(file.toPath()));
result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);
} catch (Exception e) {
log.error(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
return result;
}
read.html에서 이미지 li 부분에 th:data-file
애트리뷰트를 추가하여 썸네일 파일 경로를 저장합니다. 그리고 스크립트에서 이 경로와 size=1을 제공한 이미지 URL을 호출하여 Controller에서 원본 이미지를 가져오도록 합니다.
read.html
<div class="uploadResult">
<ul>
//수정
<li th:each="movieImage: ${dto.imageDTOList}" th:data-file="${movieImage.getThumbnailURL()}">
<img th:if="${movieImage.path != null}" th:src="|/display?fileName=${movieImage.getThumbnailURL()}|">
</li>
</ul>
</div>
...
