diff --git a/src/main/java/io/github/petty/community/Comments.java b/src/main/java/io/github/petty/community/Comments.java deleted file mode 100644 index 83ce39c..0000000 --- a/src/main/java/io/github/petty/community/Comments.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class Comments { -} diff --git a/src/main/java/io/github/petty/community/CommunityController.java b/src/main/java/io/github/petty/community/CommunityController.java deleted file mode 100644 index 8dba077..0000000 --- a/src/main/java/io/github/petty/community/CommunityController.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class CommunityController { -} diff --git a/src/main/java/io/github/petty/community/CommunityRepository.java b/src/main/java/io/github/petty/community/CommunityRepository.java deleted file mode 100644 index 5283571..0000000 --- a/src/main/java/io/github/petty/community/CommunityRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public interface CommunityRepository { -} diff --git a/src/main/java/io/github/petty/community/CommunityService.java b/src/main/java/io/github/petty/community/CommunityService.java deleted file mode 100644 index 9adfebb..0000000 --- a/src/main/java/io/github/petty/community/CommunityService.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class CommunityService { -} diff --git a/src/main/java/io/github/petty/community/PostLike.java b/src/main/java/io/github/petty/community/PostLike.java deleted file mode 100644 index facacc0..0000000 --- a/src/main/java/io/github/petty/community/PostLike.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class PostLike { -} diff --git a/src/main/java/io/github/petty/community/PostViews.java b/src/main/java/io/github/petty/community/PostViews.java deleted file mode 100644 index a548313..0000000 --- a/src/main/java/io/github/petty/community/PostViews.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class PostViews { -} diff --git a/src/main/java/io/github/petty/community/Posts.java b/src/main/java/io/github/petty/community/Posts.java deleted file mode 100644 index 0b35085..0000000 --- a/src/main/java/io/github/petty/community/Posts.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class Posts { -} diff --git a/src/main/java/io/github/petty/community/controller/CommentController.java b/src/main/java/io/github/petty/community/controller/CommentController.java new file mode 100644 index 0000000..eea029e --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/CommentController.java @@ -0,0 +1,56 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.community.service.CommentService; +import io.github.petty.users.dto.CustomUserDetails; +import io.github.petty.users.entity.Users; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + private final UsersRepository usersRepository; + + @GetMapping("/api/posts/{postId}/comments") + public ResponseEntity> getComments(@PathVariable Long postId) { + return ResponseEntity.ok(commentService.getComments(postId)); + } + + @PostMapping("/api/posts/{postId}/comments") + public ResponseEntity addComment(@PathVariable Long postId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + Long commentId = commentService.addComment(postId, request, user); + return ResponseEntity.ok().body(commentId); + } + + @PutMapping("/api/comments/{commentId}") + public ResponseEntity updateComment(@PathVariable Long commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + commentService.updateComment(commentId, request, user); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + commentService.deleteComment(commentId, user); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/io/github/petty/community/controller/PostController.java b/src/main/java/io/github/petty/community/controller/PostController.java new file mode 100644 index 0000000..36a20c1 --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostController.java @@ -0,0 +1,78 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.dto.PostDetailResponse; +import io.github.petty.community.dto.PostRequest; +import io.github.petty.community.entity.Post; +import io.github.petty.community.service.PostService; +import io.github.petty.users.dto.CustomUserDetails; +import io.github.petty.users.entity.Users; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + private final UsersRepository usersRepository; + + @PostMapping + public ResponseEntity create(@RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + Long id = postService.save(request, user); + return ResponseEntity.ok(Map.of("id", id)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + postService.update(id, request, user); + System.out.println("๐Ÿ“ฅ ์š”์ฒญ ๋“ค์–ด์˜ด: " + request); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + postService.delete(id, user); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getAllByType(@RequestParam("type") Post.PostType type, + @PageableDefault(size = 9) Pageable pageable) { + Page posts = postService.findAllByType(String.valueOf(type), pageable); + return ResponseEntity.ok(posts); + } + + @GetMapping("/{id}") + public ResponseEntity getPost(@PathVariable Long id) { + PostDetailResponse post = postService.findById(id); + return ResponseEntity.ok(post); + } + + @PostMapping("/{id}/like") + public ResponseEntity likePost(@PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + int newCount = postService.toggleLike(id, user); // ์ข‹์•„์š” ๋˜๋Š” ์ทจ์†Œ + return ResponseEntity.ok(Map.of("likeCount", newCount)); + } +} diff --git a/src/main/java/io/github/petty/community/controller/PostImageUploadController.java b/src/main/java/io/github/petty/community/controller/PostImageUploadController.java new file mode 100644 index 0000000..44cadcf --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostImageUploadController.java @@ -0,0 +1,121 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.util.SupabaseUploader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/images") +@RequiredArgsConstructor +@Slf4j +public class PostImageUploadController { + + private final SupabaseUploader supabaseUploader; + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif"); + + // โœ… ๋‹จ์ผ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + @PostMapping("/upload") + public ResponseEntity uploadImage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal UserDetails userDetails) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")); + } + + // ํŒŒ์ผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + if (!isValidImage(file)) { + return ResponseEntity.badRequest().body(Map.of( + "error", "์œ ํšจํ•˜์ง€ ์•Š์€ ํŒŒ์ผ", + "message", "5MB ์ดํ•˜์˜ jpg, jpeg, png, gif ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" + )); + } + + try { + String imageUrl = supabaseUploader.upload(file); + return ResponseEntity.ok(Map.of("url", imageUrl)); + } catch (IOException e) { + log.error("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ", e); + return ResponseEntity.internalServerError().body(Map.of("error", "์—…๋กœ๋“œ ์‹คํŒจ", "message", e.getMessage())); + } + } + + // โœ… ๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + @PostMapping("/upload/multi") + public ResponseEntity uploadMultipleImages( + @RequestParam("files") List files, + @AuthenticationPrincipal UserDetails userDetails) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")); + } + + // ํŒŒ์ผ ์ˆ˜ ์ œํ•œ + if (files.size() > 5) { + return ResponseEntity.badRequest().body(Map.of( + "error", "์ด๋ฏธ์ง€ ์ œํ•œ ์ดˆ๊ณผ", + "message", "์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" + )); + } + + // ๋ชจ๋“  ํŒŒ์ผ์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + for (MultipartFile file : files) { + if (!isValidImage(file)) { + return ResponseEntity.badRequest().body(Map.of( + "error", "์œ ํšจํ•˜์ง€ ์•Š์€ ํŒŒ์ผ", + "message", "5MB ์ดํ•˜์˜ jpg, jpeg, png, gif ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" + )); + } + } + + List> imageResponses = new ArrayList<>(); + + try { + int order = 0; + for (MultipartFile file : files) { + String url = supabaseUploader.upload(file); + + Map imageMap = new HashMap<>(); + imageMap.put("imageUrl", url); + imageMap.put("ordering", order++); + imageMap.put("isDeleted", false); + + imageResponses.add(imageMap); + } + return ResponseEntity.ok(Map.of("images", imageResponses)); + } catch (IOException e) { + log.error("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ", e); + return ResponseEntity.internalServerError().body(Map.of("error", "์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ", "message", e.getMessage())); + } + } + + private boolean isValidImage(MultipartFile file) { + if (file.isEmpty() || file.getSize() > MAX_FILE_SIZE) { + return false; + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + return false; + } + + String extension = originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase(); + return ALLOWED_EXTENSIONS.contains(extension); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/controller/PostViewController.java b/src/main/java/io/github/petty/community/controller/PostViewController.java new file mode 100644 index 0000000..49e8794 --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostViewController.java @@ -0,0 +1,63 @@ +package io.github.petty.community.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class PostViewController { + + // ๐Ÿ“Œ ํ›„๊ธฐ ๊ฒŒ์‹œํŒ + @GetMapping("/posts/review") + public String reviewListPage() { + return "post-review-list"; + } + + @GetMapping("/posts/review/new") + public String reviewFormPage() { + return "post-review-form"; + } + + @GetMapping("/posts/review/edit") + public String reviewEditPage() { + return "edit-review"; + } + + // ๐Ÿ“Œ ์ž๋ž‘ ๊ฒŒ์‹œํŒ + @GetMapping("/posts/showoff") + public String showoffListPage() { + return "post-showoff-list"; + } + + @GetMapping("/posts/showoff/new") + public String showoffFormPage() { + return "post-showoff-form"; + } + + @GetMapping("/posts/showoff/edit") + public String showoffEditPage() { + return "edit-showoff"; + } + + // ๐Ÿ“Œ ์งˆ๋ฌธ ๊ฒŒ์‹œํŒ + @GetMapping("/posts/qna") + public String qnaListPage() { + return "post-qna-list"; + } + + @GetMapping("/posts/qna/new") + public String qnaFormPage() { + return "post-qna-form"; + } + + @GetMapping("/posts/qna/edit") + public String qnaEditPage() { + return "edit-qna"; + } + + // ๐Ÿ“Œ ์ƒ์„ธํŽ˜์ด์ง€ (๊ณตํ†ต) + @GetMapping("/posts/detail") + public String detailPage() { + return "post-detail"; + } +} + diff --git a/src/main/java/io/github/petty/community/dto/CommentRequest.java b/src/main/java/io/github/petty/community/dto/CommentRequest.java new file mode 100644 index 0000000..3ecc6ca --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/CommentRequest.java @@ -0,0 +1,10 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentRequest { + private String content; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/CommentResponse.java b/src/main/java/io/github/petty/community/dto/CommentResponse.java new file mode 100644 index 0000000..13493b4 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/CommentResponse.java @@ -0,0 +1,15 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class CommentResponse { + private Long id; + private String writer; + private String content; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostDetailResponse.java b/src/main/java/io/github/petty/community/dto/PostDetailResponse.java new file mode 100644 index 0000000..55f1a47 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostDetailResponse.java @@ -0,0 +1,26 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class PostDetailResponse { + + private Long id; // ๊ฒŒ์‹œ๊ธ€ ID + private String title; // ์ œ๋ชฉ + private String content; // ๋‚ด์šฉ + private String writer; // ์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„ + private String postType; // ๊ฒŒ์‹œ๋ฌผ ์œ ํ˜• + private String petType; // ๋ฐ˜๋ ค๋™๋ฌผ ์ข…๋ฅ˜ (ํ•œ๊ธ€ ๋ผ๋ฒจ) + private String petName; // ๋ฐ˜๋ ค๋™๋ฌผ ์ด๋ฆ„ (REVIEW์—์„œ๋งŒ ์‚ฌ์šฉ) + private String region; // ์ง€์—ญ ์ •๋ณด (REVIEW์—์„œ๋งŒ ์‚ฌ์šฉ) + private Boolean isResolved; // QnA ๊ฒŒ์‹œํŒ ์—ฌ๋ถ€ (QNA ์ „์šฉ) + private int likeCount; // ์ข‹์•„์š” ์ˆ˜ + private int commentCount; // ๋Œ“๊ธ€ ์ˆ˜ + private List images; + private LocalDateTime createdAt; // ์ƒ์„ฑ์ผ์ž +} diff --git a/src/main/java/io/github/petty/community/dto/PostImageRequest.java b/src/main/java/io/github/petty/community/dto/PostImageRequest.java new file mode 100644 index 0000000..09fa063 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostImageRequest.java @@ -0,0 +1,13 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PostImageRequest { + private Long id; // ๊ธฐ์กด ์ด๋ฏธ์ง€ ์ˆ˜์ •/์‚ญ์ œํ•  ๋•Œ ํ•„์š” (์‹ ๊ทœ๋Š” null) + private String imageUrl; + private Integer ordering; // 0~4 ์ค‘ ์œ„์น˜ ์ง€์ • + private Boolean isDeleted; // ์‚ญ์ œ ์š”์ฒญ์ธ์ง€ ํ‘œ์‹œ +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/PostImageResponse.java b/src/main/java/io/github/petty/community/dto/PostImageResponse.java new file mode 100644 index 0000000..cc9f8da --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostImageResponse.java @@ -0,0 +1,16 @@ +package io.github.petty.community.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PostImageResponse { + private Long id; + private String imageUrl; + private Integer ordering; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java b/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java new file mode 100644 index 0000000..ea1d9bf --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java @@ -0,0 +1,19 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostQnaListResponse { + private Long id; + private String title; + private String petType; // ํ•œ๊ธ€ ๋ผ๋ฒจ (์˜ˆ: "๊ฐ•์•„์ง€") + private Boolean isResolved; // ํ•ด๊ฒฐ ์—ฌ๋ถ€ + private String writer; // ์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„ + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostRequest.java b/src/main/java/io/github/petty/community/dto/PostRequest.java new file mode 100644 index 0000000..caff658 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostRequest.java @@ -0,0 +1,20 @@ +package io.github.petty.community.dto; + +import io.github.petty.community.entity.Post; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PostRequest { + private String title; + private String content; + private Post.PostType postType; + private String petName; + private String petType; + private String region; + private Boolean isResolved; + private List images; +} diff --git a/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java b/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java new file mode 100644 index 0000000..527d131 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java @@ -0,0 +1,21 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostReviewListResponse { + private Long id; + private String title; + private String petName; + private String petType; // enum์˜ label ๊ฐ’ + private String region; + private String writer; // ์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„ + private String imageUrl; // ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ URL + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java b/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java new file mode 100644 index 0000000..1536d7f --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java @@ -0,0 +1,19 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostShowoffListResponse { + private Long id; + private String title; + private String petType; // ํ•œ๊ธ€ ๋ผ๋ฒจ + private String writer; // ์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„ + private String imageUrl; // ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ URL + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/entity/Comment.java b/src/main/java/io/github/petty/community/entity/Comment.java new file mode 100644 index 0000000..e5d5a90 --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/Comment.java @@ -0,0 +1,38 @@ +package io.github.petty.community.entity; + +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +@Getter +@Setter +@EqualsAndHashCode(of = "id") +@ToString(exclude = {"post", "user"}) +public class Comment { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @Column(nullable = false, length = 1000) + private String content; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; +} diff --git a/src/main/java/io/github/petty/community/entity/Post.java b/src/main/java/io/github/petty/community/entity/Post.java new file mode 100644 index 0000000..7d5a556 --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/Post.java @@ -0,0 +1,76 @@ +package io.github.petty.community.entity; + +import io.github.petty.community.enums.PetType; +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import io.github.petty.community.entity.PostImage; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.util.ArrayList; +import java.util.List; +import java.time.LocalDateTime; + +@Entity +@Table(name = "posts") +@Getter +@Setter +@ToString(exclude = "user") +@EqualsAndHashCode(exclude = "user") +public class Post { + + public enum PostType { + QNA, REVIEW, SHOWOFF + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ์ž‘์„ฑ์ž + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + // ๊ณตํ†ต ํ•„๋“œ + @Column(nullable = false, length = 50) + private String title; + + @Column(nullable = false, length = 2000) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PostType postType = PostType.REVIEW; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + private int likeCount = 0; + private int commentCount = 0; + + // ๊ฒŒ์‹œํŒ ํƒ€์ž…๋ณ„ ํ™•์žฅ ํ•„๋“œ + + // ํ›„๊ธฐ ์ „์šฉ + @Column(length = 100) + private String region; + + @Column(length = 20) + private String petName; + + // ๊ณตํ†ต + @Enumerated(EnumType.STRING) + @Column(name = "pet_type", length = 20) + private PetType petType; + + // QnA ์ „์šฉ + @Column(name = "is_resolved") + private Boolean isResolved = false; +} diff --git a/src/main/java/io/github/petty/community/entity/PostImage.java b/src/main/java/io/github/petty/community/entity/PostImage.java new file mode 100644 index 0000000..58a5fda --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/PostImage.java @@ -0,0 +1,41 @@ +package io.github.petty.community.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "post_images") +@ToString(exclude = "post") +@EqualsAndHashCode(of = "id") +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ๊ฒŒ์‹œ๊ธ€ ์—ฐ๊ด€๊ด€๊ณ„ (ํ•„์ˆ˜) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + // ์ด๋ฏธ์ง€ ์ˆœ์„œ (0~4) + @Builder.Default + @Column(nullable = false) + private Integer ordering = 0; + + // ์œ ํ‹ธ ๋ฉ”์„œ๋“œ (์–‘๋ฐฉํ–ฅ ์—ฐ๊ด€๊ด€๊ณ„ ์„ค์ •) + public void setPost(Post post) { + this.post = post; + if (!post.getImages().contains(this)) { + post.getImages().add(this); + } + } +} diff --git a/src/main/java/io/github/petty/community/entity/PostLike.java b/src/main/java/io/github/petty/community/entity/PostLike.java new file mode 100644 index 0000000..2d7a93f --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/PostLike.java @@ -0,0 +1,38 @@ +package io.github.petty.community.entity; + +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_likes") // โœ… ํ…Œ์ด๋ธ” ๋ช… ๋ช…ํ™•ํžˆ +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // โœ… JPA์šฉ ๊ธฐ๋ณธ ์ƒ์„ฑ์ž +@EqualsAndHashCode(of = "id") +@ToString(exclude = {"post", "user"}) +public class PostLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) // โœ… ๋ช…์‹œ์  join column + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @CreationTimestamp + private LocalDateTime createdAt; + + public PostLike(Post post, Users user) { + this.post = post; + this.user = user; + } +} diff --git a/src/main/java/io/github/petty/community/enums/PetType.java b/src/main/java/io/github/petty/community/enums/PetType.java new file mode 100644 index 0000000..9c8ff18 --- /dev/null +++ b/src/main/java/io/github/petty/community/enums/PetType.java @@ -0,0 +1,21 @@ +package io.github.petty.community.enums; + +public enum PetType { + DOG("๊ฐ•์•„์ง€"), + CAT("๊ณ ์–‘์ด"), + RABBIT("ํ† ๋ผ"), + HAMSTER("ํ–„์Šคํ„ฐ"), + PARROT("์•ต๋ฌด์ƒˆ"), + REPTILE("ํŒŒ์ถฉ๋ฅ˜"), + OTHER("๊ธฐํƒ€"); + + private final String label; + + PetType(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/io/github/petty/community/repository/CommentRepository.java b/src/main/java/io/github/petty/community/repository/CommentRepository.java new file mode 100644 index 0000000..0f29d5e --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/CommentRepository.java @@ -0,0 +1,19 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.post.id = :postId ORDER BY c.createdAt ASC") + List findAllByPostIdWithUser(@Param("postId") Long postId); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId") + long countByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/io/github/petty/community/repository/PostImageRepository.java b/src/main/java/io/github/petty/community/repository/PostImageRepository.java new file mode 100644 index 0000000..31fe3b0 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostImageRepository.java @@ -0,0 +1,17 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostImageRepository extends JpaRepository { + + // โœ… ๊ฒŒ์‹œ๊ธ€ ID๋กœ ๋ชจ๋“  ์ด๋ฏธ์ง€ ์กฐํšŒ (๋ฆฌ์ŠคํŠธ/์ƒ์„ธ ์ถœ๋ ฅ ์‹œ ํ•„์š”) + List findByPostIdOrderByOrderingAsc(Long postId); + + // โœ… ๊ฒŒ์‹œ๊ธ€ ID๋กœ ํ•œ ๋ฒˆ์— ์‚ญ์ œํ•˜๊ณ  ์‹ถ์„ ๋•Œ (์˜ˆ: ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ์‹œ) + void deleteByPostId(Long postId); +} diff --git a/src/main/java/io/github/petty/community/repository/PostLikeRepository.java b/src/main/java/io/github/petty/community/repository/PostLikeRepository.java new file mode 100644 index 0000000..6726334 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostLikeRepository.java @@ -0,0 +1,17 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostLike; +import io.github.petty.users.entity.Users; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PostLikeRepository extends JpaRepository { + + // โœ… ์‚ฌ์šฉ์ž๊ฐ€ ํŠน์ • ๊ฒŒ์‹œ๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆŒ๋ €๋Š”์ง€ ํ™•์ธ + Optional findByPostAndUser(Post post, Users user); + + // โœ… ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ์‹œ ์ข‹์•„์š”๋„ ํ•จ๊ป˜ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋„๋ก + void deleteAllByPost(Post post); +} diff --git a/src/main/java/io/github/petty/community/repository/PostRepository.java b/src/main/java/io/github/petty/community/repository/PostRepository.java new file mode 100644 index 0000000..6653585 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostRepository.java @@ -0,0 +1,25 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PostRepository extends JpaRepository { + @EntityGraph(attributePaths = {"user", "images"}) + Page findAllByPostTypeOrderByCreatedAtDesc(Post.PostType postType, Pageable pageable); + + @EntityGraph(attributePaths = "images") + Optional findById(Long id); + + @EntityGraph(attributePaths = {"user", "images"}) + Optional findWithUserAndImagesById(Long id); +} + diff --git a/src/main/java/io/github/petty/community/service/CommentService.java b/src/main/java/io/github/petty/community/service/CommentService.java new file mode 100644 index 0000000..6f3b2de --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentService.java @@ -0,0 +1,25 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.users.entity.Users; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public interface CommentService { + + // ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ + List getComments(Long postId); + + // ๋Œ“๊ธ€ ์ž‘์„ฑ + Long addComment(Long postId, CommentRequest request, Users user); + + // ๋Œ“๊ธ€ ์‚ญ์ œ + void deleteComment(Long commentId, Users user); + + // ๋Œ“๊ธ€ ์ˆ˜์ • + void updateComment(Long commentId, CommentRequest request, Users user); +} diff --git a/src/main/java/io/github/petty/community/service/CommentServiceImpl.java b/src/main/java/io/github/petty/community/service/CommentServiceImpl.java new file mode 100644 index 0000000..7d15f4b --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentServiceImpl.java @@ -0,0 +1,89 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.community.entity.Comment; +import io.github.petty.community.entity.Post; +import io.github.petty.community.repository.CommentRepository; +import io.github.petty.community.repository.PostRepository; +import io.github.petty.users.entity.Users; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Override + public List getComments(Long postId) { + List comments = commentRepository.findAllByPostIdWithUser(postId); + return comments.stream() + .map(comment -> CommentResponse.builder() + .id(comment.getId()) + .writer(comment.getUser().getDisplayName()) // ์•ˆ์ „ํ•˜๊ฒŒ ํ˜ธ์ถœ๋จ + .content(comment.getContent()) + .createdAt(comment.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + @Override + public Long addComment(Long postId, CommentRequest request, Users user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + Comment comment = new Comment(); + comment.setPost(post); + comment.setUser(user); + comment.setContent(request.getContent()); + + post.setCommentCount(post.getCommentCount() + 1); // ๋Œ“๊ธ€ ์ˆ˜ ์ฆ๊ฐ€ + + return commentRepository.save(comment).getId(); + } + + @Transactional + @Override + public void deleteComment(Long commentId, Users user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๋Œ“๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + Long postId = comment.getPost().getId(); + commentRepository.delete(comment); + long commentCount = commentRepository.countByPostId(postId); + + // Post๋ฅผ ์ƒˆ๋กœ ์กฐํšŒํ•ด์„œ ๋Œ“๊ธ€ ์ˆ˜ ์—…๋ฐ์ดํŠธ + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + post.setCommentCount((int) commentCount); + postRepository.save(post); + } + + @Override + public void updateComment(Long commentId, CommentRequest request, Users user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๋Œ“๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + comment.setContent(request.getContent()); + commentRepository.save(comment); + } +} diff --git a/src/main/java/io/github/petty/community/service/PostImageService.java b/src/main/java/io/github/petty/community/service/PostImageService.java new file mode 100644 index 0000000..8b4748b --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostImageService.java @@ -0,0 +1,28 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.PostImageRequest; +import io.github.petty.community.dto.PostImageResponse; +import io.github.petty.community.entity.Post; + +import java.util.List; + +public interface PostImageService { + + // โœ… ์ด๋ฏธ์ง€ ์ €์žฅ (์‹ ๊ทœ ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์‹œ) + void saveImages(Post post, List imageRequests); + + // โœ… ์ด๋ฏธ์ง€ ์‚ญ์ œ (๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ์‹œ) + void deleteImagesByPostId(Long postId); + + // โœ… ์ด๋ฏธ์ง€ ํ•˜๋‚˜ ์‚ญ์ œ + void deleteImage(Long imageId); + + // โœ… ์ด๋ฏธ์ง€ ๋ชฉ๋ก ์กฐํšŒ (์ƒ์„ธ ์กฐํšŒ์šฉ) + List findImageResponsesByPostId(Long postId); + + // โœ… ์ด๋ฏธ์ง€ ์ˆœ์„œ ๋ณ€๊ฒฝ + void reorderImages(List orderedImageIds); + + // โœ… ์ˆ˜์ • ์š”์ฒญ ์ฒ˜๋ฆฌ (isDeleted, ordering ํฌํ•จ) + void updateImagesFromRequest(Post post, List imageRequests); +} diff --git a/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java b/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java new file mode 100644 index 0000000..e4da1cd --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java @@ -0,0 +1,90 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.PostImageRequest; +import io.github.petty.community.dto.PostImageResponse; +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostImage; +import io.github.petty.community.repository.PostImageRepository; +import io.github.petty.community.util.SupabaseUploader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PostImageServiceImpl implements PostImageService { + + private final PostImageRepository postImageRepository; + private final SupabaseUploader supabaseUploader; + + @Override + public void saveImages(Post post, List imageRequests) { + if (imageRequests == null || imageRequests.isEmpty()) return; + + for (PostImageRequest dto : imageRequests) { + PostImage image = PostImage.builder() + .imageUrl(dto.getImageUrl()) + .ordering(dto.getOrdering()) + .post(post) + .build(); + postImageRepository.save(image); + } + } + + @Override + public void deleteImagesByPostId(Long postId) { + postImageRepository.deleteByPostId(postId); + } + + @Override + public void deleteImage(Long imageId) { + postImageRepository.deleteById(imageId); + } + + @Override + public List findImageResponsesByPostId(Long postId) { + return postImageRepository.findByPostIdOrderByOrderingAsc(postId).stream() + .map(img -> new PostImageResponse(img.getId(), img.getImageUrl(), img.getOrdering())) + .toList(); + } + + @Override + @Transactional + public void reorderImages(List orderedImageIds) { + for (int i = 0; i < orderedImageIds.size(); i++) { + Long imageId = orderedImageIds.get(i); + PostImage image = postImageRepository.findById(imageId) + .orElseThrow(() -> new IllegalArgumentException("์ด๋ฏธ์ง€ ID๊ฐ€ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")); + image.setOrdering(i); // ์ƒˆ๋กœ์šด ์ˆœ์„œ๋กœ ์—…๋ฐ์ดํŠธ + } + } + + @Override + @Transactional + public void updateImagesFromRequest(Post post, List imageRequests) { + for (PostImageRequest dto : imageRequests) { + if (Boolean.TRUE.equals(dto.getIsDeleted())) { + if (dto.getId() != null) { + PostImage image = postImageRepository.findById(dto.getId()) + .orElseThrow(() -> new IllegalArgumentException("์ด๋ฏธ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + postImageRepository.deleteById(dto.getId()); + supabaseUploader.delete(image.getImageUrl()); // โœ… Supabase์—์„œ ์‚ญ์ œ + } + } else if (dto.getId() != null) { + PostImage image = postImageRepository.findById(dto.getId()) + .orElseThrow(() -> new IllegalArgumentException("์ด๋ฏธ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + image.setImageUrl(dto.getImageUrl()); + image.setOrdering(dto.getOrdering()); + } else { + PostImage newImage = new PostImage(); + newImage.setImageUrl(dto.getImageUrl()); + newImage.setOrdering(dto.getOrdering()); + newImage.setPost(post); + postImageRepository.save(newImage); + } + } + } +} + diff --git a/src/main/java/io/github/petty/community/service/PostService.java b/src/main/java/io/github/petty/community/service/PostService.java new file mode 100644 index 0000000..ce21fda --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostService.java @@ -0,0 +1,15 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.*; +import io.github.petty.users.entity.Users; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostService { + Long save(PostRequest request, Users user); + void update(Long id, PostRequest request, Users user); + void delete(Long id, Users user); + Page findAllByType(String type, Pageable pageable); + PostDetailResponse findById(Long id); + int toggleLike(Long postId, Users user); +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/service/PostServiceImpl.java b/src/main/java/io/github/petty/community/service/PostServiceImpl.java new file mode 100644 index 0000000..54aec60 --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostServiceImpl.java @@ -0,0 +1,175 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.*; +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostLike; +import io.github.petty.community.enums.PetType; +import io.github.petty.community.repository.PostLikeRepository; +import io.github.petty.community.repository.PostRepository; +import io.github.petty.users.entity.Users; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final PostImageService postImageService; + private final PostLikeRepository postLikeRepository; + + @Override + public Long save(PostRequest request, Users user) { + Post post = new Post(); + post.setUser(user); + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setPostType(request.getPostType()); + post.setPetName(request.getPetName()); + if (request.getPetType() != null && !request.getPetType().isBlank()) { + post.setPetType(PetType.valueOf(request.getPetType())); + } else { + post.setPetType(null); + } + post.setRegion(request.getRegion()); + post.setIsResolved(request.getIsResolved()); + post.setLikeCount(0); + postRepository.save(post); + + postImageService.saveImages(post, request.getImages()); + + return post.getId(); + } + + @Override + @Transactional + public void update(Long id, PostRequest request, Users user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + System.out.println("๐Ÿ›  ๊ธฐ์กด Post: " + post); + if (!post.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setPetName(request.getPetName()); + if (request.getPetType() != null && !request.getPetType().isBlank()) { + post.setPetType(PetType.valueOf(request.getPetType())); + } else { + post.setPetType(null); // ๋˜๋Š” PetType.OTHER ๋กœ ๊ธฐ๋ณธ๊ฐ’ ์ง€์ •๋„ ๊ฐ€๋Šฅ + } + post.setRegion(request.getRegion()); + post.setIsResolved(request.getIsResolved()); + + if (request.getImages() != null) { + postImageService.updateImagesFromRequest(post, request.getImages()); + } + System.out.println("๐Ÿ”ง ์ˆ˜์ • ํ›„ Post: " + post); + postRepository.save(post); + } + + @Override + @Transactional + public int toggleLike(Long postId, Users user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + Optional existing = postLikeRepository.findByPostAndUser(post, user); + if (existing.isPresent()) { + postLikeRepository.delete(existing.get()); + post.setLikeCount(post.getLikeCount() - 1); + } else { + PostLike like = new PostLike(post, user); + postLikeRepository.save(like); + post.setLikeCount(post.getLikeCount() + 1); + } + + return post.getLikeCount(); + } + + @Override + public void delete(Long id, Users user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + if (!post.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + postLikeRepository.deleteAllByPost(post); + postRepository.delete(post); + } + + @Override + public Page findAllByType(String type, Pageable pageable) { + Post.PostType postType = Post.PostType.valueOf(type.toUpperCase()); + Page posts = postRepository.findAllByPostTypeOrderByCreatedAtDesc(postType, pageable); + + return switch (postType) { + case REVIEW -> posts.map(post -> PostReviewListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petName(post.getPetName()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .region(post.getRegion()) + .writer(post.getUser().getDisplayName()) + .imageUrl(post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + case QNA -> posts.map(post -> PostQnaListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .isResolved(Boolean.TRUE.equals(post.getIsResolved())) + .writer(post.getUser().getDisplayName()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + case SHOWOFF -> posts.map(post -> PostShowoffListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .writer(post.getUser().getDisplayName()) + .imageUrl(post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + }; + } + + @Override + public PostDetailResponse findById(Long id) { + Post post = postRepository.findWithUserAndImagesById(id) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + List imageResponses = post.getImages().stream() + .map(img -> new PostImageResponse(img.getId(), img.getImageUrl(), img.getOrdering())) + .toList(); + + return PostDetailResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .writer(post.getUser().getDisplayName()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .petName(post.getPetName()) + .region(post.getRegion()) + .isResolved(post.getIsResolved()) + .likeCount(post.getLikeCount()) + .postType(post.getPostType().name()) + .commentCount(post.getCommentCount()) + .images(imageResponses) + .createdAt(post.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/util/SupabaseUploader.java b/src/main/java/io/github/petty/community/util/SupabaseUploader.java new file mode 100644 index 0000000..0f99c90 --- /dev/null +++ b/src/main/java/io/github/petty/community/util/SupabaseUploader.java @@ -0,0 +1,109 @@ +package io.github.petty.community.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SupabaseUploader { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${supabase.url}") + private String supabaseUrl; + + @Value("${supabase.key}") + private String supabaseKey; + + @Value("${supabase.bucket}") + private String bucketName; + + public String upload(MultipartFile file) throws IOException { + log.info("Uploading file: {}", file.getOriginalFilename()); + log.info("Supabase URL: {}", supabaseUrl); + log.info("Bucket name: {}", bucketName); + + // 1. ๊ณ ์œ ํ•œ ํŒŒ์ผ ์ด๋ฆ„ ์ƒ์„ฑ + String rawFilename = UUID.randomUUID() + "." + getExtension(file.getOriginalFilename()); + String encodedFilename = URLEncoder.encode(rawFilename, StandardCharsets.UTF_8); + + // ์‹ค์ œ ์—…๋กœ๋“œ ์‹œ์—” ์ธ์ฝ”๋”ฉ๋œ ์ด๋ฆ„ ์‚ฌ์šฉ + String uploadUrl = supabaseUrl + "/storage/v1/object/" + bucketName + "/" + encodedFilename; + + // ์—…๋กœ๋“œ ์„ฑ๊ณต ํ›„ ์ ‘๊ทผ์šฉ URL - ์ธ์ฝ”๋”ฉ๋œ ํŒŒ์ผ๋ช… ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•ด์•ผ ํ•จ + String publicUrl = supabaseUrl + "/storage/v1/object/public/" + bucketName + "/" + encodedFilename; + + // 2. ์š”์ฒญ ํ—ค๋” + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.set("Authorization", "Bearer " + supabaseKey); + + // 3. ์š”์ฒญ ๋ณธ๋ฌธ + HttpEntity requestEntity = new HttpEntity<>(file.getBytes(), headers); + + try { + // 4. ์—…๋กœ๋“œ ์š”์ฒญ + ResponseEntity response = restTemplate.exchange( + uploadUrl, + HttpMethod.PUT, + requestEntity, + String.class + ); + + if (response.getStatusCode() == HttpStatus.OK || response.getStatusCode() == HttpStatus.CREATED) { + log.info("Upload successful: {}", publicUrl); + return publicUrl; + } else { + log.error("Upload failed with status: {}, response: {}", response.getStatusCode(), response.getBody()); + throw new RuntimeException("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ: " + response.getStatusCode()); + } + } catch (Exception e) { + log.error("Error uploading to Supabase: ", e); + throw new IOException("Supabase ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: " + e.getMessage()); + } + } + + private String getExtension(String originalFilename) { + return originalFilename.substring(originalFilename.lastIndexOf('.') + 1); + } + + public void delete(String imageUrl) { + try { + String filename = extractFilename(imageUrl); + String deleteUrl = supabaseUrl + "/storage/v1/object/" + bucketName + "/" + filename; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + supabaseKey); + + HttpEntity request = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(deleteUrl, HttpMethod.DELETE, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.error("Supabase ์‚ญ์ œ ์‹คํŒจ: {} - {}", response.getStatusCode(), response.getBody()); + } + } catch (HttpClientErrorException.NotFound e) { + log.warn("์‚ญ์ œํ•˜๋ ค๋Š” ์ด๋ฏธ์ง€๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜์ง€ ์•Š์Œ: {}", imageUrl); + // 404๋Š” ๋ฌด์‹œํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ + } catch (Exception e) { + log.error("Error deleting from Supabase: ", e); + // ๋‹ค๋ฅธ ์—๋Ÿฌ๋„ ๋ฌด์‹œํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ + } + } + + private String extractFilename(String imageUrl) { + // https://teuocihdergygykvrqck.supabase.co/storage/v1/object/public/post-images/ํŒŒ์ผ๋ช….png + return imageUrl.substring(imageUrl.lastIndexOf('/') + 1); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java index a4a6a72..546369a 100644 --- a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java +++ b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java @@ -38,7 +38,7 @@ @EnableJpaRepositories( basePackages = { "io.github.petty.users.repository", -// "io.github.petty.community.repository", + "io.github.petty.community.repository", }, entityManagerFactoryRef = "supabaseEntityManagerFactory", transactionManagerRef = "supabaseTransactionManager" @@ -73,8 +73,8 @@ public LocalContainerEntityManagerFactoryBean supabaseEntityManagerFactory( return builder.dataSource(dataSource) .packages( - "io.github.petty.users.entity" -// "io.github.petty.community.entity" + "io.github.petty.users.entity", + "io.github.petty.community.entity" ).persistenceUnit("supabase") // ์ค‘๋ณต X .properties(jpaProperties) // ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ X .build(); diff --git a/src/main/resources/static/css/common-styles.css b/src/main/resources/static/css/common-styles.css new file mode 100644 index 0000000..ee1149d --- /dev/null +++ b/src/main/resources/static/css/common-styles.css @@ -0,0 +1,406 @@ +/* PETTY - ๊ณตํ†ต CSS ์Šคํƒ€์ผ */ + +:root { + /* ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ */ + --primary: #FF9933; /* ์ฃผ ๋ธŒ๋žœ๋“œ ์ƒ‰์ƒ: ๋ฐ์€ ์˜ค๋ Œ์ง€ */ + --secondary: #66CCFF; /* ๋ณด์กฐ ์ƒ‰์ƒ: ํ•˜๋Š˜์ƒ‰ */ + --accent: #FF6666; /* ๊ฐ•์กฐ ์ƒ‰์ƒ: ์—ฐํ•œ ๋นจ๊ฐ• */ + --text-dark: #333333; /* ์–ด๋‘์šด ํ…์ŠคํŠธ */ + --text-medium: #666666; /* ์ค‘๊ฐ„ ํ…์ŠคํŠธ */ + --text-light: #999999; /* ๋ฐ์€ ํ…์ŠคํŠธ */ + --bg-light: #F9F9F9; /* ๋ฐฐ๊ฒฝ ๋ฐ์€ ์ƒ‰ */ + --bg-white: #FFFFFF; /* ํฐ์ƒ‰ ๋ฐฐ๊ฒฝ */ + --shadow: rgba(0, 0, 0, 0.1); /* ๊ทธ๋ฆผ์ž ์ƒ‰์ƒ */ + + /* ๋™๋ฌผ ํƒ€์ž… ์ƒ‰์ƒ */ + --dog: #4dabf7; /* ๊ฐ•์•„์ง€: ํŒŒ๋ž€์ƒ‰ */ + --cat: #69db7c; /* ๊ณ ์–‘์ด: ์—ฐ๋‘์ƒ‰ */ + --bird: #ff8787; /* ์ƒˆ: ๋ถ„ํ™์ƒ‰ */ + --rabbit: #ffd43b; /* ํ† ๋ผ: ๋…ธ๋ž€์ƒ‰ */ + --hamster: #b197fc; /* ํ–„์Šคํ„ฐ: ๋ณด๋ผ์ƒ‰ */ + --reptile: #63e6be; /* ํŒŒ์ถฉ๋ฅ˜: ๋ฏผํŠธ์ƒ‰ */ + --other: #ffa94d; /* ๊ธฐํƒ€: ์ฃผํ™ฉ์ƒ‰ */ +} + +body { + font-family: 'Noto Sans KR', sans-serif; + margin: 0; + padding: 0; + background-color: var(--bg-light); + color: var(--text-dark); +} + +/* ํผ ์ปจํ…Œ์ด๋„ˆ ์Šคํƒ€์ผ */ +.container { + max-width: 700px; + margin: 40px auto; + padding: 0 20px; +} + +.form-card { + background: var(--bg-white); + border-radius: 16px; + box-shadow: 0 10px 30px var(--shadow); + padding: 40px; + overflow: hidden; + position: relative; +} + +.form-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 6px; + background: var(--primary); +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-medium); + font-size: 14px; + text-decoration: none; + margin-bottom: 15px; +} + +.back-link:hover { + color: var(--primary); +} + +.back-link svg { + width: 16px; + height: 16px; +} + +h2 { + font-size: 24px; + font-weight: 700; + margin: 0 0 30px 0; + color: var(--text-dark); +} + +.form-group { + margin-bottom: 25px; +} + +/* ๋ผ๋ฒจ ์Šคํƒ€์ผ */ +label { + display: block; + font-size: 15px; + font-weight: 600; + color: var(--text-dark); + margin-bottom: 8px; +} + +/* ์ธํ’‹ ํ•„๋“œ ์Šคํƒ€์ผ */ +.input-field { + width: 100%; + padding: 12px 15px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 15px; + color: var(--text-dark); + box-sizing: border-box; + transition: all 0.3s ease; +} + +.input-field:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 3px rgba(255, 153, 51, 0.2); +} + +.input-field::placeholder { + color: #adb5bd; +} + +/* ํ…์ŠคํŠธ ์—์–ด๋ฆฌ์–ด ์Šคํƒ€์ผ */ +textarea.input-field { + min-height: 180px; + resize: vertical; +} + +/* ์…€๋ ‰ํŠธ ํ•„๋“œ ์Šคํƒ€์ผ */ +select.input-field { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23adb5bd' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 15px center; + background-size: 16px; + padding-right: 40px; +} + +/* ๋ผ๋””์˜ค ๋ฒ„ํŠผ ๊ทธ๋ฃน ์Šคํƒ€์ผ */ +.radio-group { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 10px; +} + +.radio-item { + position: relative; +} + +.radio-item input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.radio-item label { + margin: 0; + display: block; + padding: 10px 20px; + border: 2px solid #e9ecef; + border-radius: 30px; + font-size: 14px; + font-weight: 500; + color: var(--text-medium); + cursor: pointer; + transition: all 0.3s ease; +} + +.radio-item input[type="radio"]:checked + label { + background-color: var(--primary); + color: white; + border-color: var(--primary); +} + +.radio-item label:hover { + border-color: #ced4da; +} + +/* ์ฒดํฌ๋ฐ•์Šค ์Šคํƒ€์ผ */ +.checkbox { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary); +} + +.checkbox label { + margin: 0; + font-weight: normal; + font-size: 14px; +} + +/* ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์˜์—ญ ์Šคํƒ€์ผ */ +.upload-area { + border: 2px dashed #ced4da; + border-radius: 12px; + padding: 25px; + text-align: center; + background-color: #f8f9fa; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 8px; +} + +.upload-area:hover { + border-color: var(--primary); + background-color: rgba(255, 153, 51, 0.05); +} + +.upload-area .icon { + font-size: 30px; + color: #adb5bd; + margin-bottom: 10px; +} + +.upload-area .title { + font-size: 16px; + font-weight: 600; + color: var(--text-medium); + margin-bottom: 5px; +} + +.upload-area .browse { + display: inline-block; + padding: 8px 20px; + background-color: #f1f3f5; + border-radius: 30px; + font-size: 14px; + color: var(--text-medium); + font-weight: 500; + margin-bottom: 10px; + transition: all 0.3s ease; +} + +.upload-area .browse:hover { + background-color: #e9ecef; +} + +.upload-area input[type="file"] { + display: none; +} + +.upload-area .formats { + font-size: 12px; + color: var(--text-light); +} + +/* ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์˜์—ญ */ +.image-preview { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 15px; +} + +.preview-item { + position: relative; + width: 100px; + height: 100px; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 6px rgba(0,0,0,0.1); +} + +.preview-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-item .remove { + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + background-color: rgba(0,0,0,0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.preview-item .remove:hover { + background-color: rgba(0,0,0,0.7); +} + +/* ์•ก์…˜ ๋ฒ„ํŠผ ์Šคํƒ€์ผ */ +.form-actions { + display: flex; + justify-content: flex-end; + gap: 15px; + margin-top: 35px; +} + +.btn { + padding: 12px 24px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + transition: all 0.3s ease; + cursor: pointer; + border: none; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: #e67e00; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 153, 51, 0.3); +} + +.btn-secondary { + background-color: #f1f3f5; + color: var(--text-medium); +} + +.btn-secondary:hover { + background-color: #e9ecef; + transform: translateY(-2px); +} + +/* ์ƒํƒœ ํ‘œ์‹œ ์Šคํƒ€์ผ */ +.status-group { + margin-top: 10px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 60px; + height: 30px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #e9ecef; + transition: .4s; + border-radius: 30px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 24px; + width: 24px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: #69db7c; +} + +input:checked + .toggle-slider:before { + transform: translateX(30px); +} + +.toggle-label { + margin-left: 10px; + font-size: 14px; + color: var(--text-medium); +} + +/* ๋ฐ˜์‘ํ˜• ์Šคํƒ€์ผ */ +@media (max-width: 768px) { + .form-card { + padding: 30px 20px; + } + + .radio-group { + gap: 10px; + } + + .radio-item label { + padding: 8px 16px; + font-size: 13px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/js/common/edit-qna.js b/src/main/resources/static/js/common/edit-qna.js new file mode 100644 index 0000000..541509f --- /dev/null +++ b/src/main/resources/static/js/common/edit-qna.js @@ -0,0 +1,175 @@ +let originalImages = []; +let postType = "QNA"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + // HTML์˜ ์‹ค์ œ ID์™€ ๋งž์ถค + const imageInput = document.getElementById("edit-qna-imageFiles"); + if (imageInput) { + imageInput.addEventListener("change", handleImageUpload); + } + + const form = document.getElementById("editQnaForm"); + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("edit-qna-title").value, + content: document.getElementById("edit-qna-content").value, + petType: getRadioValue("edit-qna-petType") || "OTHER", + postType: postType, + isResolved: getIsResolvedValue(), + images: originalImages + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("์ˆ˜์ • ์™„๋ฃŒ!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("์ˆ˜์ • ์‹คํŒจ"); + } + }); + } + + // isResolved ํ† ๊ธ€ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ + const resolvedCheckbox = document.getElementById("isResolved"); + const resolvedLabel = document.getElementById("resolvedLabel"); + + if (resolvedCheckbox && resolvedLabel) { + resolvedCheckbox.addEventListener("change", () => { + resolvedLabel.textContent = resolvedCheckbox.checked ? "ํ•ด๊ฒฐ์™„๋ฃŒ" : "๋ฏธํ•ด๊ฒฐ"; + }); + } +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("edit-qna-title").value = post.title; + document.getElementById("edit-qna-content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="edit-qna-petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) input.checked = true; + }); + + const resolvedCheckbox = document.getElementById("isResolved"); + const resolvedLabel = document.getElementById("resolvedLabel"); + if (resolvedCheckbox && resolvedLabel) { + resolvedCheckbox.checked = post.isResolved; + resolvedLabel.textContent = post.isResolved ? "ํ•ด๊ฒฐ์™„๋ฃŒ" : "๋ฏธํ•ด๊ฒฐ"; + } + + const previewBox = document.getElementById("edit-qna-imagePreview"); + if (previewBox) { + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); + } +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +function getIsResolvedValue() { + const checkbox = document.getElementById("isResolved"); + return checkbox ? checkbox.checked : false; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (!res.ok) { + alert("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ"); + return; + } + + const json = await res.json(); + const previewBox = document.getElementById("edit-qna-imagePreview"); + + if (previewBox && json.images) { + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); + } +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} \ No newline at end of file diff --git a/src/main/resources/static/js/common/edit-review.js b/src/main/resources/static/js/common/edit-review.js new file mode 100644 index 0000000..3262380 --- /dev/null +++ b/src/main/resources/static/js/common/edit-review.js @@ -0,0 +1,159 @@ +let originalImages = []; +let postType = "REVIEW"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + // HTML์˜ ์‹ค์ œ ID์™€ ๋งž์ถค + const imageInput = document.getElementById("edit-review-imageFiles"); + if (imageInput) { + imageInput.addEventListener("change", handleImageUpload); + } + + const form = document.getElementById("editReviewForm"); + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("edit-review-title").value, + content: document.getElementById("edit-review-content").value, + petType: getRadioValue("edit-review-petType") || "OTHER", + petName: document.getElementById("edit-petName").value, + region: document.getElementById("edit-region").value, + postType: postType, + images: originalImages + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("์ˆ˜์ • ์™„๋ฃŒ!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("์ˆ˜์ • ์‹คํŒจ"); + } + }); + } +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("edit-review-title").value = post.title; + document.getElementById("edit-review-content").value = post.content; + document.getElementById("edit-region").value = post.region || ""; + document.getElementById("edit-petName").value = post.petName || ""; + + const petTypeInputs = document.querySelectorAll('input[name="edit-review-petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("edit-review-imagePreview"); + if (previewBox) { + (post.images || []).forEach((img) => { + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); + } +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (!res.ok) { + alert("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ"); + return; + } + + const json = await res.json(); + const previewBox = document.getElementById("edit-review-imagePreview"); + + if (previewBox && json.images) { + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); + } +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} \ No newline at end of file diff --git a/src/main/resources/static/js/common/edit-showoff.js b/src/main/resources/static/js/common/edit-showoff.js new file mode 100644 index 0000000..d39f6d1 --- /dev/null +++ b/src/main/resources/static/js/common/edit-showoff.js @@ -0,0 +1,154 @@ +let originalImages = []; +let postType = "SHOWOFF"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + // HTML์˜ ์‹ค์ œ ID์™€ ๋งž์ถค + const imageInput = document.getElementById("edit-showoff-imageFiles"); + if (imageInput) { + imageInput.addEventListener("change", handleImageUpload); + } + + const form = document.getElementById("editShowoffForm"); + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("edit-showoff-title").value, + content: document.getElementById("edit-showoff-content").value, + petType: getRadioValue("edit-showoff-petType") || "OTHER", + postType: postType, + images: originalImages + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("์ˆ˜์ • ์™„๋ฃŒ!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("์ˆ˜์ • ์‹คํŒจ"); + } + }); + } +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("edit-showoff-title").value = post.title; + document.getElementById("edit-showoff-content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="edit-showoff-petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("edit-showoff-imagePreview"); + if (previewBox) { + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); + } +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (!res.ok) { + alert("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ"); + return; + } + + const json = await res.json(); + const previewBox = document.getElementById("edit-showoff-imagePreview"); + + if (previewBox && json.images) { + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + imgWrapper.style.display = "inline-block"; + imgWrapper.style.margin = "5px"; + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); + } +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} \ No newline at end of file diff --git a/src/main/resources/static/js/common/form.js b/src/main/resources/static/js/common/form.js new file mode 100644 index 0000000..9e47869 --- /dev/null +++ b/src/main/resources/static/js/common/form.js @@ -0,0 +1,183 @@ +let uploadedImages = []; + +document.addEventListener('DOMContentLoaded', () => { + // ๊ฐ ํŽ˜์ด์ง€๋ณ„๋กœ ์ง์ ‘ ID๋ฅผ ํ™•์ธํ•˜์—ฌ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก + const imageFileElements = [ + 'imageFiles', // QNA ํŽ˜์ด์ง€ + 'review-imageFiles', // Review ํŽ˜์ด์ง€ + 'showoff-imageFiles' // Showoff ํŽ˜์ด์ง€ + ]; + + imageFileElements.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('change', handleImageUpload); + } + }); + + const formElements = [ + 'postForm', // QNA ํŽ˜์ด์ง€ + 'reviewForm', // Review ํŽ˜์ด์ง€ + 'showoffForm' // Showoff ํŽ˜์ด์ง€ + ]; + + formElements.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('submit', handleFormSubmit); + } + }); +}); + +async function handleImageUpload(e) { + const MAX_FILE_COUNT = 5; + const MAX_FILE_SIZE_MB = 5; + + const files = Array.from(e.target.files); + + if (files.length > MAX_FILE_COUNT) { + alert(`์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ ${MAX_FILE_COUNT}์žฅ๊นŒ์ง€๋งŒ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.`); + e.target.value = ''; + return; + } + + for (const file of files) { + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + alert(`ํŒŒ์ผ ${file.name}์€(๋Š”) 5MB๋ฅผ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค.`); + e.target.value = ''; + return; + } + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + + if (!res.ok) { + alert("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ: " + json.message); + return; + } + + if (!json.images || !Array.isArray(json.images)) { + alert("์ด๋ฏธ์ง€ ์‘๋‹ต์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค"); + return; + } + + for (let img of json.images) { + console.log("์—…๋กœ๋“œ๋œ ์ด๋ฏธ์ง€:", img.imageUrl); + } + + uploadedImages.push(...json.images); + + // ํŽ˜์ด์ง€๋ณ„๋กœ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปจํ…Œ์ด๋„ˆ ์ฐพ๊ธฐ + const previewIds = ['imagePreview', 'review-imagePreview', 'showoff-imagePreview']; + let previewBox = null; + + for (const id of previewIds) { + const element = document.getElementById(id); + if (element) { + previewBox = element; + break; + } + } + + if (!previewBox) { + console.warn('์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์ด๋ฏธ์ง€๋“ค ์ถ”๊ฐ€ (์›๋ž˜ ๋ฐฉ์‹๋Œ€๋กœ) + json.images.forEach((img) => { + if (uploadedImages.some(existing => existing.imageUrl === img.imageUrl && existing !== img)) return; + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +async function handleFormSubmit(e) { + e.preventDefault(); + + // ํŽ˜์ด์ง€๋ณ„๋กœ ์š”์†Œ ์ฐพ๊ธฐ + const titleElement = document.getElementById('title') || + document.getElementById('review-title') || + document.getElementById('showoff-title'); + + const contentElement = document.getElementById('content') || + document.getElementById('review-content') || + document.getElementById('showoff-content'); + + const petNameElement = document.getElementById('petName'); + const regionElement = document.getElementById('region'); + + const postData = { + title: titleElement?.value || '', + content: contentElement?.value || '', + petType: getRadioValue('petType') || getRadioValue('review-petType') || getRadioValue('showoff-petType') || 'OTHER', + petName: petNameElement?.value || null, + region: regionElement?.value || null, + postType: detectPostType(), + isResolved: false, + images: uploadedImages + }; + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(postData) + }); + + if (res.ok) { + const { id } = await res.json(); + alert('๋“ฑ๋ก ์™„๋ฃŒ!'); + location.href = `/posts/detail?id=${id}`; + } else { + alert('๋“ฑ๋ก ์‹คํŒจ ๐Ÿ˜ข'); + } +} + +// ์—…๋กœ๋“œ๋œ ์ด๋ฏธ์ง€ ์‚ญ์ œ ํ•จ์ˆ˜ ์ถ”๊ฐ€ +function removeUploadedImage(url) { + const index = uploadedImages.findIndex(img => img.imageUrl === url); + if (index > -1) { + uploadedImages.splice(index, 1); + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +function detectPostType() { + if (location.pathname.includes('review')) return 'REVIEW'; + if (location.pathname.includes('qna')) return 'QNA'; + if (location.pathname.includes('showoff')) return 'SHOWOFF'; + return 'REVIEW'; +} \ No newline at end of file diff --git a/src/main/resources/templates/edit-qna.html b/src/main/resources/templates/edit-qna.html new file mode 100644 index 0000000..4f64aeb --- /dev/null +++ b/src/main/resources/templates/edit-qna.html @@ -0,0 +1,102 @@ + + + + + + PETTY - ์งˆ๋ฌธ ์ˆ˜์ •ํ•˜๊ธฐ + + + +
+ + + + + + ๋Œ์•„๊ฐ€๊ธฐ + + +
+

์งˆ๋ฌธ ์ˆ˜์ •ํ•˜๊ธฐ

+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + ๋ฏธํ•ด๊ฒฐ +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/edit-review.html b/src/main/resources/templates/edit-review.html new file mode 100644 index 0000000..5f4a6b6 --- /dev/null +++ b/src/main/resources/templates/edit-review.html @@ -0,0 +1,101 @@ + + + + + + PETTY - ํ›„๊ธฐ ์ˆ˜์ •ํ•˜๊ธฐ + + + +
+ + + + + + ๋Œ์•„๊ฐ€๊ธฐ + + +
+

ํ›„๊ธฐ ์ˆ˜์ •ํ•˜๊ธฐ

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/edit-showoff.html b/src/main/resources/templates/edit-showoff.html new file mode 100644 index 0000000..458f153 --- /dev/null +++ b/src/main/resources/templates/edit-showoff.html @@ -0,0 +1,91 @@ + + + + + + PETTY - ์ž๋ž‘๊ธ€ ์ˆ˜์ •ํ•˜๊ธฐ + + + +
+ + + + + + ๋Œ์•„๊ฐ€๊ธฐ + + +
+

์ž๋ž‘๊ธ€ ์ˆ˜์ •ํ•˜๊ธฐ

+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index d168429..46bf26f 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -57,6 +57,7 @@

ํšŒ์›๊ฐ€์ž… ์—๋Ÿฌ ํ™•์ธ์šฉ