728x90
[구현 기능 목표]
1. 댓글 좋아요 카운트
2. 한 사용자가 댓글을 한번만 누를 수 있다. (중복 클릭 불가능)
3-1. 현재 로그인된 사용자가 해당 댓글에 좋아요를 눌렀을 경우 - 채워진 좋아요 아이콘
3-2. 현재 로그인된 사용자가 해당 댓글에 좋아요 누르지 않았을 경우 - 속이 빈 좋아요 아이콘
[참고 대상 - 유튜브 댓글]
[디자인]
[ Entity ) CommentLikes.class ]
@Entity
@Getter
@Setter
public class CommentLikes {
@Id
@GeneratedValue
@Column(name = "commentLikes_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
}
member 가 comment 를 좋아요 눌렀는지 확인하기 위한 엔티티 이다.
[ Repository ) CommentLikesRepository.interface ]
public interface CommentLikesRepository extends JpaRepository<CommentLikes, Long> {
Boolean existsByMemberAndComment(Member member, Comment comment);
Optional<CommentLikes> findByMemberAndComment(Member member, Comment comment);
}
- existsByMemberAndComment(Member member, Comment comment)
: member 와 comment 사이의 commentLikes 가 존재하는가?
존재 O -> true
존재 X -> false
[ DTO ) CommentResponseDto.class ]
@Data
public class CommentResponseDto {
private Long id;
private String content;
private String memberNickname;
private Integer likes;
private String createdAt;
private String updatedAt;
private Long memberId;
private Long postId;
private Boolean liked; //현재 로그인된 회원이 좋아요를 눌렀는가?
/*Entity -> DTO*/
public CommentResponseDto(Comment comment) {
this.id = comment.getId();
this.content = comment.getContent();
this.memberNickname = comment.getMember().getNickname();
this.likes = comment.getLikes();
this.createdAt = comment.getCreatedAt();
this.updatedAt = comment.getUpdatedAt();
this.memberId = comment.getMember().getId();
this.postId = comment.getPost().getId();
}
}
'liked' 필드는 '현재 로그인된 회원' 이 comment 를 좋아요 누른적이 있는가? true / false 를 알려주는 필드이다.
[ Service ) CommentLikesService.class ]
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentLikesService {
private final CommentLikesRepository commentLikesRepository;
private final MemberRepository memberRepository;
private final CommentRepository commentRepository;
/* member -> comment 좋아요(likes) 로직
* 이미 좋아요가 눌러진 상태라면, 좋아요 취소*/
@Transactional
public Boolean toggleLike(Long memberId, Long commentId) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new DataNotFoundException("존재하지 않는 회원입니다."));
Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new DataNotFoundException("존재하지 않는 댓글입니다."));
//이미 좋아요를 누른 경우
if (commentLikesRepository.existsByMemberAndComment(member, comment)) {
// 이미 좋아요를 눌렀다면 좋아요 취소
CommentLikes commentLikes = commentLikesRepository.findByMemberAndComment(member, comment)
.orElseThrow(() -> new DataNotFoundException("좋아요 정보를 찾을 수 없습니다."));
commentLikesRepository.delete(commentLikes); //likes 정보 삭제
comment.decrementLikes(); //댓글 좋아요 수 -1
return false;
} else {
CommentLikes commentLikes = new CommentLikes();
commentLikes.setMember(member);
commentLikes.setComment(comment);
commentLikesRepository.save(commentLikes);
comment.incrementLikes();
return true;
}
}
/*member -> comment 이미 좋아요를 눌렀는지 확인하는 로직*/
public boolean isLikedByMember(Long memberId, Long commentId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new DataNotFoundException("존재하지 않는 회원입니다."));
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new DataNotFoundException("존재하지 않는 댓글입니다."));
/*이미 좋아요 누름 : true
* 좋아요 누른적 없음 : false*/
return commentLikesRepository.existsByMemberAndComment(member, comment);
}
}
[ Controller ) CommentLikesController.class ]
@Controller
@RequestMapping("/comment-likes")
@RequiredArgsConstructor
public class CommentLikesController {
private final CommentLikesService commentLikesService;
// 좋아요 토글 API
/* "/comment-likes/{commentId}/toggle/{postId}" */
@PostMapping("/{commentId}/toggle/{postId}")
public String toggleLike(@RequestParam Long memberId, @PathVariable Long commentId, RedirectAttributes redirectAttributes,
@PathVariable Long postId) {
try {
commentLikesService.toggleLike(memberId, commentId);
redirectAttributes.addFlashAttribute("message", "좋아요 상태가 변경되었습니다.");
} catch (DataNotFoundException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
} catch (RuntimeException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
}
return "redirect:/posts/" + postId;
}
}
[ Controller ) PostController.class 中... ]
/**
* 게시글(Post) 상세 정보 - "/posts/{postId}"
* 해당 게시글의 댓글 목록 + 작성 폼
*/
@GetMapping("/{postId}")
public String postInfo(@PathVariable Long postId,
@PageableDefault(sort = "id", size = 5, direction = Sort.Direction.DESC) Pageable pageable,
Model model) {
/* Header.html - 현재 로그인된 회원 */
Member currentMember = memberService.getCurrentMember();
model.addAttribute("memberId", currentMember.getId());
/* 게시글 상세정보 */
PostResponseDto postInfo = postService.getPostInfo(postId);
model.addAttribute("postInfo", postInfo);
/* 좋아요 여부 */
boolean isLiked = likesService.isLikedByMember(currentMember.getId(), postId);
model.addAttribute("isLiked", isLiked);
/**Comment List**/
/* 댓글 작성 폼 */
model.addAttribute("createCommentForm", new CommentRequestDto());
/* 댓글 목록 */
Page<Comment> commentList = commentService.findCommentList(postId, pageable);
Page<CommentResponseDto> list = commentList.map(comment -> {
CommentResponseDto commentResponseDto = new CommentResponseDto(comment);
commentResponseDto.setLiked((commentLikesService.isLikedByMember(currentMember.getId(), comment.getId())));
return commentResponseDto;
});
model.addAttribute("comments", list); //comment dto list
model.addAttribute("previous", pageable.previousOrFirst().getPageNumber()); //이전 페이지 정보
model.addAttribute("next", pageable.next().getPageNumber()); //다음 페이지 정보
model.addAttribute("hasPrevious", list.hasPrevious()); //이전 페이지 존재 여부
model.addAttribute("hasNext", list.hasNext()); //다음 페이지 존재 여부
/* 페이지 번호 */
int currentPage = pageable.getPageNumber() + 1; //현재 페이지 정보(User side)
model.addAttribute("current", currentPage);
int blockSize = 5;
int startPage = ((currentPage - 1) / blockSize) * blockSize + 1; //블럭 시작 페이지
int endPage = Math.min(startPage + blockSize - 1, list.getTotalPages()); //블럭 마지막 페이지
model.addAttribute("startPage", startPage);
model.addAttribute("endPage", endPage);
return "posts/post-info";
}
[ Thymeleaf ) comment-list.html ]
<!DOCTYPE html>
<html lang="kr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div th:fragment="comment-list">
<div th:each="comment : ${comments}">
<div class="d-flex justify-content-between">
<div class="d-flex justify-content-start">
<!--프로필 사진-->
<img class="d-flex mr-3 rounded-circle" src="http://placehold.it/30x30" alt="">
<!--작성자 닉네임-->
<p class="me-2" style="background: #9d9d9d"><small th:text="'@' + ${comment.memberNickname}">member
nickname</small></p>
<!--생성일자-->
<p><em th:text="${comment.createdAt}">created At</em></p>
</div>
<!--마크다운 메뉴-->
<div class="btn-group" role="group" aria-label="Button group with nested dropdown">
<button type="button" class="btn btn-primary btn-sm"></button>
<div class="btn-group" role="group">
<button id="btnGroupDrop1" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
<div class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<a class="dropdown-item" href="#">Dropdown link</a>
<a class="dropdown-item" href="#">Dropdown link</a>
</div>
</div>
</div>
</div>
<!--댓글 내용-->
<p><strong th:text="${comment.content}">comment content</strong></p>
<div class="d-flex justify-content-start">
<!--좋아요 버튼 "/comment-likes/{commentId}/toggle/{postId}" -->
<form th:action="@{'/comment-likes/' + ${comment.id} + '/toggle/' + ${postInfo.id} }" method="post" style="display: inline;">
<!--현재 로그인 회원 ID-->
<input type="hidden" th:name="memberId" th:value="${memberId}">
<button type="submit" style="background: none; border: none; padding: 0;">
<img class="me-2" th:src="${comment.liked} ? '/img/full_heart.png' : '/img/empty_heart.png'" style="width:18px; height:18px; cursor: pointer;" />
</button>
</form>
<!--좋아요 수-->
<p class="me-5"><em th:text="${comment.likes}">likes</em></p>
<!--답글 버튼-->
<a class="btn btn-light btn-sm" role="button">답글</a>
</div>
<hr>
</div>
</div>
</body>
</html>
[완성화면]
728x90
'PROJECT > The Board' 카테고리의 다른 글
[The Board] 댓글 / 대댓글 기능 구현 (생성, 수정 삭제) (0) | 2024.07.10 |
---|---|
[TheBoard] The Board 프로젝트 환경 설정 + 프로젝트 선정 이유 (0) | 2024.07.01 |