What to do?
좋아요 기능개발하기
기능구현한 모습
감정표현 유형
- EmotionType
- 없음 / 좋아요 / 싫어요
- getOpposite : 반대되는 감정표현을 return
@Getter
@AllArgsConstructor
public enum EmotionType {
NONE("없음", 0L),
LIKE("좋아요", 1L),
HATE("싫어요", 2L);
private final String description;
private final Long seq;
public EmotionType getOpposite(){
return switch (this){
case NONE -> NONE;
case LIKE -> HATE;
case HATE -> LIKE;
};
}
}
- EmotionActionType
- NEW
- NONE → LIKE
- NONE → HATE
- SWITCH
- HATE → LIKE
- LIKE → HATE
- CANCEL
- LIKE → NONE
- HATE → NONE
- NEW
@Getter
@AllArgsConstructor
public enum EmotionActionType {
NEW("새로운 좋아요/싫어요 요청"),
SWITCH("좋아요→싫어요 or 싫어요→좋아요"),
CANCEL("좋아요/싫어요 취소");
private final String description;
}
Entity, Dto 설계
- EmotionEntity.java
- 누가(→user) 어떤 포스팅에(→post) 어떤 감정표현을(→emotionType) 했는지 표현할 수 있도록 만듬
@Setter
@Getter
@Entity
@Table(name = "emotion")
@SQLDelete(sql = "UPDATE emotion SET removed_at = NOW() WHERE id=?")
@Where(clause = "removed_at is NULL")
public class EmotionEntity extends AuditingFields {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id")
private UserEntity user;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id")
private PostEntity post;
@Enumerated(EnumType.STRING)
private EmotionType emotionType;
private EmotionEntity(UserEntity user, PostEntity post, EmotionType emotionType) {
this.user = user;
this.post = post;
this.emotionType = emotionType;
}
protected EmotionEntity(){}
public static EmotionEntity of(UserEntity user, PostEntity post, EmotionType emotionType) {
return new EmotionEntity(user, post, emotionType);
}
public static EmtionDto dto(EmotionEntity entity){
return EmtionDto.of(
entity.getUser().getUsername(),
entity.getPost().getId(),
entity.getEmotionType()
);
}
}
- EmotionDto.java
@Getter
@Setter
public class EmtionDto {
private String username;
private Long postId;
private EmotionType emotionType;
private EmtionDto(String username, Long postId, EmotionType emotionType) {
this.username = username;
this.postId = postId;
this.emotionType = emotionType;
}
protected EmtionDto(){}
public static EmtionDto of(String username, Long postId, EmotionType emotionType) {
return new EmtionDto(username, postId, emotionType);
}
}
Repository
- findByUserAndPost
- 어떤 포스팅을 조회했을 때, 내가 예전에 포스팅에 좋아요를 눌렀는지 알아야 함
- User와 Post를 받아서 EmotionEntity를 조회해야 함
- countByPostAndEmotionType
- 어떤 포스팅에 좋아요, 싫어요 개수를 알아야 함
- Post와 EmotionType을 조회해서 count를 해야 함
@Repository
public interface EmotionRepository extends JpaRepository<EmotionEntity, Long> {
Optional<EmotionEntity> findByUserAndPost(UserEntity user, PostEntity post);
Optional<EmotionEntity> findByUserAndPostAndEmotionType(UserEntity user, PostEntity post, EmotionType emotionType);
// 좋아요/싫어요 개수 가져오기
Long countByPostAndEmotionType(PostEntity post, EmotionType emotionType);
}
PostService
- getEmotionInfo
- 좋아요 개수
- 싫어요 개수
- 로그인한 유저가 좋아요를 눌렀는지 여부
- 좋아요 누름 → LIKE
- 싫어요 누름 → HATE
- 아무것도 안 누름 → NONE
/**
* 좋아요 & 싫어요 개수, 포스팅 좋아요 & 싫어요 여부
* @param postId 좋아요 & 싫어요 포스트 id
*/
@Transactional(readOnly = true)
public Map<String, Object> getEmotionInfo(UserEntity user, Long postId){
PostEntity post = findByPostIdOrElseThrow(postId);
EmotionEntity like = emotionRepository.findByUserAndPost(user, post).orElse(EmotionEntity.of(user, post, EmotionType.NONE));
return Map.of(
"LIKE", emotionRepository.countByPostAndEmotionType(post, EmotionType.LIKE),
"HATE", emotionRepository.countByPostAndEmotionType(post, EmotionType.HATE),
"EMOTION", like.getEmotionType()
);
}
- handleEmotion
- emotionType == NEW
- NONE → LIKE or NONE → HATE
- 기존에 감정표현이 있으면 Error
- 새로운 감정표현 저장
- emotionType == SWITCH
- LIKE → HATE or HATE → LIKE
- 기존에 반대되는 emotionType을 가진 emotionEntity를 찾음
- emotionType수정해서 저장
- emotionType == NONE
- LIKE → NONE or HATE → NONE
- 기존에 있는 emotionEntity 삭제
- emotionType == NEW
/**
* 좋아요 & 싫어요 요청
* @param postId 좋아요 & 싫어요 포스트 id
* @param emotionType LIKE(좋아요), HATE(싫어요)
* @param user like 누른사람
*/
@Transactional
public void handleEmotion(UserEntity user, Long postId, EmotionType emotionType, EmotionActionType emotionActionType){
PostEntity post = findByPostIdOrElseThrow(postId);
switch (emotionActionType){
case NEW -> {
emotionRepository.findByUserAndPost(user, post).ifPresent(it->{
throw CustomException.of(CustomErrorCode.ALREADY_LIKED);
});
emotionRepository.save(EmotionEntity.of(user, post, emotionType));
}
case SWITCH -> {
emotionRepository
.findByUserAndPostAndEmotionType(user, post, emotionType.getOpposite())
.ifPresent(it ->{
it.setEmotionType(emotionType);
emotionRepository.save(it);
});
}
case CANCEL -> {
emotionRepository.findByUserAndPost(user, post).ifPresent(it->{
emotionRepository.deleteById(it.getId());
});
}
}
}
Controller
- EmotionRequest.java
- Client가 Server로 좋아요/싫어요 요청할 때 Client가 보내는 데이터 형태
- emotionActionType : NEW, SWITCH, CANCEL
- emotionType : LIKE, HATE
@Getter
@AllArgsConstructor
public class EmotionRequest {
private EmotionActionType emotionActionType;
private EmotionType emotionType;
}
- EmotionResponse.java
- Client가 Server로부터 좋아요/싫어요 개수, 유저가 좋아요/싫어요 눌렀는지 여부에 대해서 요청하면, Server가 Client에게 응답하는 데이터 형태
- likeCount : 좋아요 개수
- hateCount : 싫어요 개수
- emotionType : LIKE, HATE, NONE 중 하나
@Getter
public class EmotionResponse {
private Long likeCount;
private Long hateCount;
private String emotionType;
private EmotionResponse(Long likeCount, Long hateCount, String emotionType) {
this.likeCount = likeCount;
this.hateCount = hateCount;
this.emotionType = emotionType;
}
protected EmotionResponse(){}
public static EmotionResponse of(Long likeCount, Long hateCount, String emotionType){
return new EmotionResponse(likeCount, hateCount, emotionType);
}
public static EmotionResponse from(Map<String, Object> map){
return EmotionResponse.of((Long) map.get("LIKE"), (Long) map.get("HATE"), map.get("EMOTION").toString());
}
}
- PostController.java
@RequestMapping("/api/v1")
public class PostController {
private final PostService postService;
...
// 좋아요 & 싫어요 개수 가져오기 & 유저가 해당 포스팅을 좋아하는지 여부
@GetMapping("/emotion")
public CustomResponse<EmotionResponse> getEmotionInfo(@RequestParam("pid") Long postId, Authentication authentication){
UserEntity user = (UserEntity) authentication.getPrincipal();
return CustomResponse.success(EmotionResponse.from(postService.getEmotionInfo(user, postId)));
}
// 좋아요 & 싫어요 요청
@PostMapping("/emotion")
public CustomResponse<Void> handleEmotion(
@RequestParam("pid") Long postId,
@RequestBody EmotionRequest req,
Authentication authentication
){
UserEntity user = (UserEntity) authentication.getPrincipal();
postService.handleEmotion(user, postId, req.getEmotionType(), req.getEmotionActionType());
return CustomResponse.success();
}
}
Front-End(React)
- DetailPost.js
import { styled } from '@mui/material/styles';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Collapse from '@mui/material/Collapse';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { red } from '@mui/material/colors';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SendIcon from '@mui/icons-material/Send';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Container } from '@mui/system';
import axios from 'axios';
import { userState } from '../../recoil/user';
import { useRecoilState } from 'recoil';
import { Box, Button, DialogContent, Grid, Pagination, TextField, Tooltip } from '@mui/material';
import FullScreenDialog from './FullScreenDialog';
import ListItemText from '@mui/material/ListItemText';
import ListItem from '@mui/material/ListItem';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';
const ExpandMore = styled((props) => {
const { expand, ...other } = props;
return <IconButton {...other} />;
})(({ theme, expand }) => ({
transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
}));
const DetailPost = ({postId}) => {
/**
* States
* 포스팅 - title, content, author, createAt, hashtags
* 좋아요, 싫어요 - likeCount, hateCount, emotionType(LIKE, HATE, NONE)
* 댓글 - comments, userComment
* 페이지 - totalPage, expand
*/
const [user, setUser] = useRecoilState(userState);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [author, setAuthor] = useState("");
const [createdAt, setCreatedAt] = useState("");
const [hashtags, setHashtags] = useState([""]);
const [likeCount, setLikeCount] = useState(0);
const [hateCount, setHateCount] = useState(0);
const [emotionType, setEmotionType] = useState(null);
const [emotionActionType, setEmotionActionType] = useState(null);
const [comments, setComments] = useState([]);
const [userComment, setUserComment] = useState("");
const [currentPage, setCurrentPage] = useState(0);
const [totalPage, setTotalPage] = useState(1);
const [expanded, setExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// ------- hooks ------- //
// 포스팅 정보 가져오기
useEffect(()=>{
const endPoint = `/api/v1/post/detail?pid=${postId}`;
axios
.get(endPoint, {
headers:{
Authorization:user.token??localStorage.getItem("token")
}
}).then((res)=>{
return res.data.result
}).then((res)=>{
setTitle(res.title);
setContent(res.content);
setAuthor(res.nickname);
setCreatedAt(res.createdAt);
setHashtags(res.hashtags);
handleLikeCount();
}).catch((err)=>{
console.log("useEffect 포스팅 정보 가져오기",err);
})
}, [])
// 댓글창
useEffect(()=>{
const endPoint = `/api/v1/comment?pid=${postId}&page=${currentPage}`
// 댓글창이 열린경우
if (expanded){
axios
.get(endPoint, {
headers:{
Authorization:user.token??localStorage.getItem("token")
}
}).then((res)=>{
return res.data.result;
}).then((res)=>{
setComments([...res.content]); // 댓글
setCurrentPage(res.pageable.pageNumber); // 페이지
setTotalPage(res.totalPages); // 전체페이지
}).catch((err)=>{
console.log("useEffect 댓글 정보 가져오기",err);
});
}
}, [expanded, isLoading, currentPage])
// ------- handeler -------
// 댓글창 열기 / 닫기
const handleExpandClick = () => {
setExpanded(!expanded);
};
// 댓글 최대 500자
const handleUserComment = (e) => {
setUserComment(e.target.value.slice(0, 500));
}
// 댓글 입력
const handleSumbitComment = async (e) => {
const endPoint = `/api/v1/comment`
setIsLoading(true);
await axios.post(endPoint, {postId, content:userComment}, {
headers:{
Authorization:user.token??localStorage.getItem("token")
}
}).then((res)=>{
setUserComment("");
}).catch((err)=>{
console.log("handleSumbitComment",err);
}).finally(()=>{
setIsLoading(false);
})
}
const handleLikeCount = async () => {
const endPoint = `/api/v1/emotion?pid=${postId}`;
await axios.get(endPoint, {
headers:{
Authorization:user.token??localStorage.getItem("token")
}
}).then((res)=>{
return res.data.result;
}).then((res)=>{
setLikeCount(res.likeCount);
setHateCount(res.hateCount);
setEmotionType(res.emotionType);
}).catch((err)=>{
console.log("감정표현 정보가져오기",err);
})
}
const handleLike = (type) => async (e) => {
let data = {};
const endPoint = `api/v1/emotion?pid=${postId}`;
setIsLoading(true);
if (emotionType === "NONE"){
data = {emotionType:type, emotionActionType:"NEW"};
setEmotionType(type);
} else if (type === emotionType){
data = {emotionType:type, emotionActionType:"CANCEL"};
setEmotionType("NONE");
} else {
data = {emotionType:type, emotionActionType:"SWITCH"};
setEmotionType(type==="LIKE"?"HATE":"LIKE");
}
await axios.post(endPoint, data, {
headers:{
Authorization:user.token??localStorage.getItem("token")
}
}).then((res)=>{
handleLikeCount();
}).catch((err)=>{
console.log("handleLike",err);
}).finally(()=>{
setIsLoading(false);
})
}
const handleCommentPage = (e) => {
setCurrentPage((parseInt(e.target.outerText)??1)-1);
}
return (
<Card>
{/* 작성자 & 작성시간 */}
<CardHeader
avatar={
<Avatar sx={{ bgcolor: red[500] }} aria-label="recipe">
R
</Avatar>
}
title={author}
subheader={createdAt}
/>
{/* TODO : 이미지 삽입기능 */}
{/* <CardMedia
component="img"
height="194"
image="/static/images/cards/paella.jpg"
alt="Paella dish"
/> */}
{/* 본문 */}
<CardContent>
<TextField value={content} sx={{width:"100%"}} multiline variant='filled'/>
<Divider />
</CardContent>
{/* 해쉬태그 */}
<Box sx={{display:'flex', width:'100%', padding:'1vh'}} color="primary">
{hashtags.map((h, i)=>{
return(
<Typography variant="span" component="span" color="primary" key={i} sx={{marginLeft:"2vh"}}>
#{h}
</Typography>
)
})}
<Divider />
</Box>
<CardActions disableSpacing>
{/* 좋아요 아이콘 */}
<IconButton onClick={handleLike("LIKE")}>
<ThumbUpIcon sx={{color:(emotionType==="LIKE")?"red":"gray"}}/> {likeCount}
</IconButton>
{/* 싫어요 아이콘 */}
<IconButton onClick={handleLike("HATE")}>
<ThumbDownIcon sx={{color:(emotionType==="HATE")?"blue":"gray"}}/> {hateCount}
</IconButton>
<ExpandMore
expand={expanded}
onClick={handleExpandClick}
aria-expanded={expanded}>
<ExpandMoreIcon />
</ExpandMore>
</CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ flexGrow: 1, padding:'1vh' }}>
{/* 댓글 입력창 */}
<Grid container spacing={2}>
<Grid item xs={11}>
<Tooltip title="500자 내외로 댓글을 작성해주세요">
<TextField label={userComment?`${userComment.length}/500`:"댓글"} sx={{width:'100%'}}
variant="standard" multiline onChange={handleUserComment} value={userComment}/>
</Tooltip>
</Grid>
{/* 댓글 전송 */}
<Grid item xs={1}>
<Tooltip title="댓글작성">
<IconButton onClick={handleSumbitComment} disabled={isLoading} sx={{color:isLoading?"gray":"blue"}}>
<SendIcon/>
</IconButton>
</Tooltip>
</Grid>
</Grid>
</Box>
<CardContent>
{/* 댓글 */}
{
comments.map((c, i)=>{
return (
<Grid container key={i}>
<Typography variant="strong" component="div">
<strong>{c.content}</strong>
</Typography>
<Grid container>
<Grid item xs={4}>
<Typography sx={{ mb: 1.5 }} color="text.secondary">
by {c.nickname}
</Typography>
</Grid>
<Grid item xs={4}>
</Grid>
<Grid item xs={4}>
<Typography sx={{ mb: 1.5 }} color="text.secondary">
{c.createdAt}
</Typography>
</Grid>
</Grid>
</Grid>
)
})
}
{/* 댓글페이지 */}
<Box sx={{justifyContent:"center", display:"flex", marginTop:"5vh"}}>
<Pagination count={totalPage} defaultPage={currentPage+1} boundaryCount={5} onChange={handleCommentPage}
color="primary" size="large" sx={{margin: '2vh'}}/>
</Box>
</CardContent>
</Collapse>
</Card>
);
}
export default DetailPost;
'Java > Spring' 카테고리의 다른 글
[Spring] 간단한 SNS 만들기 #10 (0) | 2023.01.24 |
---|---|
[Spring] 간단한 SNS 만들기 #9 (0) | 2023.01.22 |
[Spring] 간단한 SNS 만들기 #7 (0) | 2023.01.21 |
[Spring] 간단한 SNS 만들기 #6 (0) | 2023.01.15 |
[Spring] 간단한 SNS 만들기 #5 (0) | 2023.01.15 |