From 23f545947175bc185f598818f195aed3aa55ed4a Mon Sep 17 00:00:00 2001 From: LimPark996 Date: Sun, 27 Apr 2025 02:15:58 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]=20community=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/github/petty/community/Comments.java | 4 - .../petty/community/CommunityController.java | 4 - .../petty/community/CommunityRepository.java | 4 - .../petty/community/CommunityService.java | 4 - .../io/github/petty/community/PostLike.java | 4 - .../io/github/petty/community/PostViews.java | 4 - .../java/io/github/petty/community/Posts.java | 4 - .../controller/CommentController.java | 56 ++++++ .../community/controller/PostController.java | 78 ++++++++ .../controller/PostImageUploadController.java | 121 ++++++++++++ .../controller/PostViewController.java | 63 +++++++ .../petty/community/dto/CommentRequest.java | 10 + .../petty/community/dto/CommentResponse.java | 15 ++ .../community/dto/PostDetailResponse.java | 26 +++ .../petty/community/dto/PostImageRequest.java | 13 ++ .../community/dto/PostImageResponse.java | 16 ++ .../community/dto/PostQnaListResponse.java | 19 ++ .../petty/community/dto/PostRequest.java | 20 ++ .../community/dto/PostReviewListResponse.java | 21 +++ .../dto/PostShowoffListResponse.java | 19 ++ .../petty/community/entity/Comment.java | 38 ++++ .../github/petty/community/entity/Post.java | 76 ++++++++ .../petty/community/entity/PostImage.java | 41 ++++ .../petty/community/entity/PostLike.java | 38 ++++ .../github/petty/community/enums/PetType.java | 21 +++ .../repository/CommentRepository.java | 17 ++ .../repository/PostImageRepository.java | 17 ++ .../repository/PostLikeRepository.java | 17 ++ .../community/repository/PostRepository.java | 25 +++ .../community/service/CommentService.java | 22 +++ .../community/service/CommentServiceImpl.java | 78 ++++++++ .../community/service/PostImageService.java | 28 +++ .../service/PostImageServiceImpl.java | 90 +++++++++ .../petty/community/service/PostService.java | 15 ++ .../community/service/PostServiceImpl.java | 176 ++++++++++++++++++ .../community/util/SupabaseUploader.java | 104 +++++++++++ 36 files changed, 1280 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/io/github/petty/community/Comments.java delete mode 100644 src/main/java/io/github/petty/community/CommunityController.java delete mode 100644 src/main/java/io/github/petty/community/CommunityRepository.java delete mode 100644 src/main/java/io/github/petty/community/CommunityService.java delete mode 100644 src/main/java/io/github/petty/community/PostLike.java delete mode 100644 src/main/java/io/github/petty/community/PostViews.java delete mode 100644 src/main/java/io/github/petty/community/Posts.java create mode 100644 src/main/java/io/github/petty/community/controller/CommentController.java create mode 100644 src/main/java/io/github/petty/community/controller/PostController.java create mode 100644 src/main/java/io/github/petty/community/controller/PostImageUploadController.java create mode 100644 src/main/java/io/github/petty/community/controller/PostViewController.java create mode 100644 src/main/java/io/github/petty/community/dto/CommentRequest.java create mode 100644 src/main/java/io/github/petty/community/dto/CommentResponse.java create mode 100644 src/main/java/io/github/petty/community/dto/PostDetailResponse.java create mode 100644 src/main/java/io/github/petty/community/dto/PostImageRequest.java create mode 100644 src/main/java/io/github/petty/community/dto/PostImageResponse.java create mode 100644 src/main/java/io/github/petty/community/dto/PostQnaListResponse.java create mode 100644 src/main/java/io/github/petty/community/dto/PostRequest.java create mode 100644 src/main/java/io/github/petty/community/dto/PostReviewListResponse.java create mode 100644 src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java create mode 100644 src/main/java/io/github/petty/community/entity/Comment.java create mode 100644 src/main/java/io/github/petty/community/entity/Post.java create mode 100644 src/main/java/io/github/petty/community/entity/PostImage.java create mode 100644 src/main/java/io/github/petty/community/entity/PostLike.java create mode 100644 src/main/java/io/github/petty/community/enums/PetType.java create mode 100644 src/main/java/io/github/petty/community/repository/CommentRepository.java create mode 100644 src/main/java/io/github/petty/community/repository/PostImageRepository.java create mode 100644 src/main/java/io/github/petty/community/repository/PostLikeRepository.java create mode 100644 src/main/java/io/github/petty/community/repository/PostRepository.java create mode 100644 src/main/java/io/github/petty/community/service/CommentService.java create mode 100644 src/main/java/io/github/petty/community/service/CommentServiceImpl.java create mode 100644 src/main/java/io/github/petty/community/service/PostImageService.java create mode 100644 src/main/java/io/github/petty/community/service/PostImageServiceImpl.java create mode 100644 src/main/java/io/github/petty/community/service/PostService.java create mode 100644 src/main/java/io/github/petty/community/service/PostServiceImpl.java create mode 100644 src/main/java/io/github/petty/community/util/SupabaseUploader.java 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..3b136e1 --- /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..c609cc0 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/CommentRepository.java @@ -0,0 +1,17 @@ +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); + +} 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..d147a21 --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentService.java @@ -0,0 +1,22 @@ +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 java.util.List; + +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..a71161b --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentServiceImpl.java @@ -0,0 +1,78 @@ +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.stereotype.Service; + +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(); + } + + @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("삭제 권한이 없습니다."); + } + + Post post = comment.getPost(); + post.setCommentCount(Math.max(0, post.getCommentCount() - 1)); // 댓글 수 감소 + + commentRepository.delete(comment); + } + + @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..773d0e2 --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostServiceImpl.java @@ -0,0 +1,176 @@ +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); + + // ✅ 수정된 saveImages 호출 + 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..d513b83 --- /dev/null +++ b/src/main/java/io/github/petty/community/util/SupabaseUploader.java @@ -0,0 +1,104 @@ +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.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) { + 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); + try { + ResponseEntity response = restTemplate.exchange(deleteUrl, HttpMethod.DELETE, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.error("Supabase 삭제 실패: {} - {}", response.getStatusCode(), response.getBody()); + } + } 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 From 5a573c95f7a78b7779742a7a03350e411b0d5611 Mon Sep 17 00:00:00 2001 From: LimPark996 Date: Sun, 27 Apr 2025 02:18:19 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[add]=20static=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4(images,=20common=20js)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/images/no-images.jpg | Bin 0 -> 13161 bytes .../resources/static/js/common/edit-qna.js | 137 +++++++++++++++++ .../resources/static/js/common/edit-review.js | 139 ++++++++++++++++++ .../static/js/common/edit-showoff.js | 126 ++++++++++++++++ src/main/resources/static/js/common/form.js | 133 +++++++++++++++++ 5 files changed, 535 insertions(+) create mode 100644 src/main/resources/static/images/no-images.jpg create mode 100644 src/main/resources/static/js/common/edit-qna.js create mode 100644 src/main/resources/static/js/common/edit-review.js create mode 100644 src/main/resources/static/js/common/edit-showoff.js create mode 100644 src/main/resources/static/js/common/form.js diff --git a/src/main/resources/static/images/no-images.jpg b/src/main/resources/static/images/no-images.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d84a9b47a785bbbe139b1b194eb4c9317878e47c GIT binary patch literal 13161 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 J5)kKK{|!ToFERiC literal 0 HcmV?d00001 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..13140c5 --- /dev/null +++ b/src/main/resources/static/js/common/edit-qna.js @@ -0,0 +1,137 @@ +let originalImages = []; +let postType = "QNA"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "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("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) input.checked = true; + }); + + const resolvedSelect = document.getElementById("isResolved"); + if (resolvedSelect) { + resolvedSelect.value = String(post.isResolved); + } + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +function getIsResolvedValue() { + const select = document.getElementById("isResolved"); + return select ? select.value === "true" : 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 + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + 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(); +} 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..8cf00c3 --- /dev/null +++ b/src/main/resources/static/js/common/edit-review.js @@ -0,0 +1,139 @@ +let originalImages = []; +let postType = "REVIEW"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: getRadioValue("petType") || "OTHER", + petName: document.getElementById("petName").value, + region: document.getElementById("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("title").value = post.title; + document.getElementById("content").value = post.content; + document.getElementById("region").value = post.region || ""; + document.getElementById("petName").value = post.petName || ""; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img) => { + const imgWrapper = document.createElement("div"); + 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 + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ Supabase에서 PostImageRequest 형태로 응답 + + const imgWrapper = document.createElement("div"); + 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(); +} 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..33c6044 --- /dev/null +++ b/src/main/resources/static/js/common/edit-showoff.js @@ -0,0 +1,126 @@ +let originalImages = []; +let postType = "SHOWOFF"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "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("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, // 수정/삭제에 필요 + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: 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 + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ PostImageRequest 형태로 바로 push + + const imgWrapper = document.createElement("div"); + 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(); +} 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..43c263d --- /dev/null +++ b/src/main/resources/static/js/common/form.js @@ -0,0 +1,133 @@ +let uploadedImages = []; + +document.getElementById('imageFiles').addEventListener('change', async (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); // imageUrl, ordering, isDeleted 포함 + // 알림 제거함 + + const previewContainer = document.querySelector(".image-upload"); + + // 기존 이미지 미리보기 제거 + const oldPreview = document.getElementById("imagePreview"); + if (oldPreview) oldPreview.remove(); + + // 새로 미리보기 컨테이너 생성 + const previewDiv = document.createElement("div"); + previewDiv.id = "imagePreview"; + previewDiv.style.display = "flex"; + previewDiv.style.flexWrap = "wrap"; + previewDiv.style.gap = "10px"; + previewDiv.style.marginTop = "10px"; + + // 이미지들 추가 + for (const img of json.images) { + const imageEl = document.createElement("img"); + imageEl.src = img.imageUrl; + imageEl.alt = "미리보기 이미지"; + imageEl.style.width = "100px"; + imageEl.style.borderRadius = "6px"; + imageEl.style.objectFit = "cover"; + previewDiv.appendChild(imageEl); + } + + previewContainer.appendChild(previewDiv); +}); + +// 🔸 게시글 form 제출 +document.getElementById('postForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const postData = { + title: document.getElementById('title').value, + content: document.getElementById('content').value, + petType: document.getElementById('petType')?.value || getRadioValue('petType'), + petName: document.getElementById('petName')?.value || null, + region: document.getElementById('region')?.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 getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +// 🔸 postType 자동 감지 +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 From af413f293f58193a142ae0a2e08981dbde67b2b6 Mon Sep 17 00:00:00 2001 From: LimPark996 Date: Sun, 27 Apr 2025 02:22:58 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[add]=20templates=20=ED=99=94=EB=A9=B4(html?= =?UTF-8?q?)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/edit-qna.html | 133 ++++++++ src/main/resources/templates/edit-review.html | 126 ++++++++ .../resources/templates/edit-showoff.html | 124 +++++++ src/main/resources/templates/index.html | 1 + src/main/resources/templates/post-detail.html | 305 ++++++++++++++++++ .../resources/templates/post-qna-form.html | 123 +++++++ .../resources/templates/post-qna-list.html | 142 ++++++++ .../resources/templates/post-review-form.html | 122 +++++++ .../resources/templates/post-review-list.html | 145 +++++++++ .../templates/post-showoff-form.html | 123 +++++++ .../templates/post-showoff-list.html | 142 ++++++++ 11 files changed, 1486 insertions(+) create mode 100644 src/main/resources/templates/edit-qna.html create mode 100644 src/main/resources/templates/edit-review.html create mode 100644 src/main/resources/templates/edit-showoff.html create mode 100644 src/main/resources/templates/post-detail.html create mode 100644 src/main/resources/templates/post-qna-form.html create mode 100644 src/main/resources/templates/post-qna-list.html create mode 100644 src/main/resources/templates/post-review-form.html create mode 100644 src/main/resources/templates/post-review-list.html create mode 100644 src/main/resources/templates/post-showoff-form.html create mode 100644 src/main/resources/templates/post-showoff-list.html diff --git a/src/main/resources/templates/edit-qna.html b/src/main/resources/templates/edit-qna.html new file mode 100644 index 0000000..a522a21 --- /dev/null +++ b/src/main/resources/templates/edit-qna.html @@ -0,0 +1,133 @@ + + + + + + + + + 질문글 수정 + + + +
+

질문글 수정하기

+
+ + + + + + + + + + + + + +
+

🖼 이미지를 여기에 끌어다 놓거나

+ + +
+
+ +
+ + +
+
+
+ + + \ 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..ccf9852 --- /dev/null +++ b/src/main/resources/templates/edit-review.html @@ -0,0 +1,126 @@ + + + + + + + + 여행 후기 수정 + + + +
+

여행 후기 수정하기

+
+ + + + + + + + + + +
+ + + + + + + +
+ + + + +
+

📸 이미지 추가

+ +
+

최대 5개의 이미지를 업로드할 수 있습니다.

+
+ +
+ + +
+
+
+ + + diff --git a/src/main/resources/templates/edit-showoff.html b/src/main/resources/templates/edit-showoff.html new file mode 100644 index 0000000..ddf0b01 --- /dev/null +++ b/src/main/resources/templates/edit-showoff.html @@ -0,0 +1,124 @@ + + + + + + 자랑글 수정 + + + +
+

자랑글 수정하기

+
+ + + + + + + + + + +
+

🖼 이미지를 여기에 드래그하거나

+ + +
+
+ +
+ + +
+
+
+ + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 292e2cd..43534a8 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -57,6 +57,7 @@

회원가입 에러 확인용