From de54fb242015e3d168f8801fdbac794dae63f0c7 Mon Sep 17 00:00:00 2001 From: jeongho Date: Fri, 3 Oct 2025 14:53:50 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=ED=8C=AC=EC=95=84=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../art/application/ArtController.java | 72 ++++ .../ticketing/art/application/ArtService.java | 119 ++++++ .../com/example/ticketing/art/domain/Art.java | 95 +++++ .../ticketing/art/domain/ArtComment.java | 46 +++ .../art/domain/ArtCommentRepository.java | 19 + .../example/ticketing/art/domain/ArtLike.java | 35 ++ .../art/domain/ArtLikeRepository.java | 17 + .../ticketing/art/domain/ArtRepository.java | 34 ++ .../ticketing/art/dto/ArtCreateRequest.java | 32 ++ .../ticketing/art/dto/ArtResponse.java | 44 +++ .../ticketing/art/dto/ArtUpdateRequest.java | 19 + .../ticketing/view/ViewController.java | 16 + .../resources/static/css/art/art-create.css | 284 ++++++++++++++ .../resources/static/css/art/art-detail.css | 150 ++++++++ .../resources/static/css/art/art-gallery.css | 349 ++++++++++++++++++ src/main/resources/static/js/art/create.js | 197 ++++++++++ src/main/resources/static/js/art/detail.js | 288 +++++++++++++++ src/main/resources/static/js/art/gallery.js | 232 ++++++++++++ src/main/resources/static/js/header.js | 6 +- src/main/resources/templates/art/create.html | 76 ++++ src/main/resources/templates/art/detail.html | 49 +++ src/main/resources/templates/art/gallery.html | 37 ++ .../resources/templates/fragments/header.html | 2 + 23 files changed, 2216 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/ticketing/art/application/ArtController.java create mode 100644 src/main/java/com/example/ticketing/art/application/ArtService.java create mode 100644 src/main/java/com/example/ticketing/art/domain/Art.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtComment.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtCommentRepository.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtLike.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtLikeRepository.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtRepository.java create mode 100644 src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java create mode 100644 src/main/java/com/example/ticketing/art/dto/ArtResponse.java create mode 100644 src/main/java/com/example/ticketing/art/dto/ArtUpdateRequest.java create mode 100644 src/main/resources/static/css/art/art-create.css create mode 100644 src/main/resources/static/css/art/art-detail.css create mode 100644 src/main/resources/static/css/art/art-gallery.css create mode 100644 src/main/resources/static/js/art/create.js create mode 100644 src/main/resources/static/js/art/detail.js create mode 100644 src/main/resources/static/js/art/gallery.js create mode 100644 src/main/resources/templates/art/create.html create mode 100644 src/main/resources/templates/art/detail.html create mode 100644 src/main/resources/templates/art/gallery.html diff --git a/src/main/java/com/example/ticketing/art/application/ArtController.java b/src/main/java/com/example/ticketing/art/application/ArtController.java new file mode 100644 index 0000000..3d6a2a2 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/application/ArtController.java @@ -0,0 +1,72 @@ +package com.example.ticketing.art.application; + +import com.example.ticketing.art.dto.ArtCreateRequest; +import com.example.ticketing.art.dto.ArtResponse; +import com.example.ticketing.art.dto.ArtUpdateRequest; +import com.example.ticketing.common.auth.Auth; +import com.example.ticketing.common.auth.ClientInfo; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/arts") +@RequiredArgsConstructor +public class ArtController { + + private final ArtService artService; + + @GetMapping + public ResponseEntity> getPublicArts( + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page response = artService.getPublicArts(pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/{artId}") + public ResponseEntity getArt(@PathVariable Long artId) { + ArtResponse response = artService.getArt(artId); + return ResponseEntity.ok(response); + } + + @GetMapping("/my") + public ResponseEntity> getMyArts( + @Auth ClientInfo clientInfo, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page response = artService.getMyArts(clientInfo, pageable); + return ResponseEntity.ok(response); + } + + @PostMapping + public ResponseEntity createArt( + @Valid @RequestBody ArtCreateRequest request, + @Auth ClientInfo clientInfo) { + log.info("Request 정보 = {}", request); + ArtResponse response = artService.createArt(request, clientInfo); + return ResponseEntity.ok(response); + } + + @PutMapping("/{artId}") + public ResponseEntity updateArt( + @PathVariable Long artId, + @Valid @RequestBody ArtUpdateRequest request, + @Auth ClientInfo clientInfo) { + ArtResponse response = artService.updateArt(artId, request, clientInfo); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{artId}") + public ResponseEntity deleteArt( + @PathVariable Long artId, + @Auth ClientInfo clientInfo) { + artService.deleteArt(artId, clientInfo); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/application/ArtService.java b/src/main/java/com/example/ticketing/art/application/ArtService.java new file mode 100644 index 0000000..9500c14 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/application/ArtService.java @@ -0,0 +1,119 @@ +package com.example.ticketing.art.application; + +import com.example.ticketing.art.domain.Art; +import com.example.ticketing.art.domain.ArtRepository; +import com.example.ticketing.art.dto.ArtCreateRequest; +import com.example.ticketing.art.dto.ArtResponse; +import com.example.ticketing.art.dto.ArtUpdateRequest; +import com.example.ticketing.client.component.ClientManager; +import com.example.ticketing.client.domain.Client; +import com.example.ticketing.common.auth.ClientInfo; +import com.example.ticketing.common.exception.ErrorCode; +import com.example.ticketing.common.exception.GlobalException; +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArtService { + + private final ArtRepository artRepository; + private final ClientManager clientManager; + private final ObjectMapper objectMapper; + + @Transactional + public ArtResponse createArt(ArtCreateRequest request, ClientInfo clientInfo) { + Client client = clientManager.findById(clientInfo.getClientId()); + validatePixelData(request.getPixelData(), request.getWidth(), request.getHeight()); + + Art art = Art.builder() + .title(request.getTitle()) + .description(request.getDescription()) + .pixelData(request.getPixelData()) + .width(request.getWidth()) + .height(request.getHeight()) + .isPublic(request.getIsPublic()) + .client(client) + .build(); + + Art savedArt = artRepository.save(art); + return ArtResponse.from(savedArt); + } + + public Page getPublicArts(Pageable pageable) { + Page arts = artRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable); + return arts.map(ArtResponse::from); + } + + public ArtResponse getArt(Long artId) { + Art art = artRepository.findByIdAndIsPublicTrue(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + // 조회수 증가 + art.incrementViewCount(); + artRepository.save(art); + + return ArtResponse.from(art); + } + + public Page getMyArts(ClientInfo clientInfo, Pageable pageable) { + Client client = clientManager.findById(clientInfo.getClientId()); + Page arts = artRepository.findByClientAndIsPublicTrueOrderByCreatedAtDesc(client, pageable); + return arts.map(ArtResponse::from); + } + + @Transactional + public ArtResponse updateArt(Long artId, ArtUpdateRequest request, ClientInfo clientInfo) { + Art art = artRepository.findById(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + if (!art.getClient().getId().equals(clientInfo.getClientId())) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + art.updateInfo(request.getTitle(), request.getDescription()); + if (request.getIsPublic() != null) { + art.toggleVisibility(); + } + + return ArtResponse.from(art); + } + + @Transactional + public void deleteArt(Long artId, ClientInfo clientInfo) { + Art art = artRepository.findById(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + if (!art.getClient().getId().equals(clientInfo.getClientId())) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + artRepository.delete(art); + } + + private void validatePixelData(String pixelData, Integer width, Integer height) { + if (pixelData == null || pixelData.isEmpty()) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + // 30x30 고정 크기 검증 + if (width != 30 || height != 30) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + // 픽셀 데이터 길이 검증 + if (pixelData.length() != width * height) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + // 0과 1로만 구성되어 있는지 검증 + if (!pixelData.matches("[01]+")) { + throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/Art.java b/src/main/java/com/example/ticketing/art/domain/Art.java new file mode 100644 index 0000000..6001b26 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/Art.java @@ -0,0 +1,95 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Art { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 1000) + private String description; + + @Column(nullable = false) + private String pixelData; + + @Column(nullable = false) + private Integer width; + + @Column(nullable = false) + private Integer height; + + @Column(nullable = false) + @Builder.Default + private Integer likeCount = 0; + + @Column(nullable = false) + @Builder.Default + private Integer viewCount = 0; + + @Column(nullable = false) + @Builder.Default + private Boolean isPublic = true; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @OneToMany(mappedBy = "art", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List likes = new ArrayList<>(); + + @OneToMany(mappedBy = "art", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List comments = new ArrayList<>(); + + @CreatedDate + @Column(nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void incrementViewCount() { + this.viewCount++; + } + + public void updateInfo(String title, String description) { + this.title = title; + this.description = description; + } + + public void toggleVisibility() { + this.isPublic = !this.isPublic; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtComment.java b/src/main/java/com/example/ticketing/art/domain/ArtComment.java new file mode 100644 index 0000000..0fa735d --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtComment.java @@ -0,0 +1,46 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class ArtComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 500) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "art_id", nullable = false) + private Art art; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @CreatedDate + @Column(nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public void updateContent(String content) { + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtCommentRepository.java b/src/main/java/com/example/ticketing/art/domain/ArtCommentRepository.java new file mode 100644 index 0000000..77e11e2 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtCommentRepository.java @@ -0,0 +1,19 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ArtCommentRepository extends JpaRepository { + + Page findByArtOrderByCreatedAtDesc(Art art, Pageable pageable); + + List findByArtOrderByCreatedAtDesc(Art art); + + Long countByArt(Art art); + + void deleteByArtAndClient(Art art, Client client); +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtLike.java b/src/main/java/com/example/ticketing/art/domain/ArtLike.java new file mode 100644 index 0000000..db19222 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtLike.java @@ -0,0 +1,35 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"art_id", "client_id"})) +public class ArtLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "art_id", nullable = false) + private Art art; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @CreatedDate + @Column(nullable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtLikeRepository.java b/src/main/java/com/example/ticketing/art/domain/ArtLikeRepository.java new file mode 100644 index 0000000..0b5077a --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtLikeRepository.java @@ -0,0 +1,17 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ArtLikeRepository extends JpaRepository { + + Optional findByArtAndClient(Art art, Client client); + + boolean existsByArtAndClient(Art art, Client client); + + void deleteByArtAndClient(Art art, Client client); + + Long countByArt(Art art); +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtRepository.java b/src/main/java/com/example/ticketing/art/domain/ArtRepository.java new file mode 100644 index 0000000..5e4b59c --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtRepository.java @@ -0,0 +1,34 @@ +package com.example.ticketing.art.domain; + +import com.example.ticketing.client.domain.Client; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ArtRepository extends JpaRepository { + + Page findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable); + + Page findByIsPublicTrueOrderByLikeCountDescCreatedAtDesc(Pageable pageable); + + Page findByIsPublicTrueOrderByViewCountDescCreatedAtDesc(Pageable pageable); + + Page findByClientAndIsPublicTrueOrderByCreatedAtDesc(Client client, Pageable pageable); + + @Query("SELECT a FROM Art a WHERE a.isPublic = true AND " + + "(LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(a.description) LIKE LOWER(CONCAT('%', :keyword, '%')))") + Page findByKeywordAndIsPublicTrue(@Param("keyword") String keyword, Pageable pageable); + + Optional findByIdAndIsPublicTrue(Long id); + + List findTop10ByIsPublicTrueOrderByLikeCountDescCreatedAtDesc(); + + @Query("SELECT COUNT(a) FROM Art a WHERE a.client = :client") + Long countByClient(@Param("client") Client client); +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java b/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java new file mode 100644 index 0000000..440554e --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java @@ -0,0 +1,32 @@ +package com.example.ticketing.art.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ArtCreateRequest { + + @NotBlank(message = "제목을 입력해주세요.") + @Size(max = 100, message = "제목은 100자 이하로 입력해주세요.") + private String title; + + @NotBlank(message = "설명을 입력해주세요.") + @Size(max = 1000, message = "설명은 1000자 이하로 입력해주세요.") + private String description; + + @NotBlank(message = "픽셀 데이터를 입력해주세요.") + private String pixelData; + + @NotNull(message = "가로 크기를 입력해주세요.") + @Positive(message = "가로 크기는 양수여야 합니다.") + private Integer width; + + @NotNull(message = "세로 크기를 입력해주세요.") + @Positive(message = "세로 크기는 양수여야 합니다.") + private Integer height; + + private Boolean isPublic = true; +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/dto/ArtResponse.java b/src/main/java/com/example/ticketing/art/dto/ArtResponse.java new file mode 100644 index 0000000..bf8584c --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtResponse.java @@ -0,0 +1,44 @@ +package com.example.ticketing.art.dto; + +import com.example.ticketing.art.domain.Art; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class ArtResponse { + + private Long id; + private String title; + private String description; + private String pixelData; + private Integer width; + private Integer height; + private Integer likeCount; + private Integer viewCount; + private Boolean isPublic; + private String authorName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean isLikedByCurrentUser; + + public static ArtResponse from(Art art) { + return ArtResponse.builder() + .id(art.getId()) + .title(art.getTitle()) + .description(art.getDescription()) + .pixelData(art.getPixelData()) + .width(art.getWidth()) + .height(art.getHeight()) + .likeCount(art.getLikeCount()) + .viewCount(art.getViewCount()) + .isPublic(art.getIsPublic()) + .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") + .createdAt(art.getCreatedAt()) + .updatedAt(art.getUpdatedAt()) + .isLikedByCurrentUser(false) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/dto/ArtUpdateRequest.java b/src/main/java/com/example/ticketing/art/dto/ArtUpdateRequest.java new file mode 100644 index 0000000..bbe4596 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtUpdateRequest.java @@ -0,0 +1,19 @@ +package com.example.ticketing.art.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ArtUpdateRequest { + + @NotBlank(message = "제목을 입력해주세요.") + @Size(max = 100, message = "제목은 100자 이하로 입력해주세요.") + private String title; + + @NotBlank(message = "설명을 입력해주세요.") + @Size(max = 1000, message = "설명은 1000자 이하로 입력해주세요.") + private String description; + + private Boolean isPublic; +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/view/ViewController.java b/src/main/java/com/example/ticketing/view/ViewController.java index cf8abd3..f33e43e 100644 --- a/src/main/java/com/example/ticketing/view/ViewController.java +++ b/src/main/java/com/example/ticketing/view/ViewController.java @@ -39,4 +39,20 @@ public String blogList(Model model) { public String blogContents(@PathVariable("id") String id, Model model) { return "blog/" + id; } + + @GetMapping("/art") + public String artGallery(Model model) { + return "art/gallery"; + } + + @GetMapping("/art/create") + public String artCreate(Model model) { + return "art/create"; + } + + @GetMapping("/art/{id}") + public String artDetail(@PathVariable("id") Long id, Model model) { + model.addAttribute("artId", id); + return "art/detail"; + } } diff --git a/src/main/resources/static/css/art/art-create.css b/src/main/resources/static/css/art/art-create.css new file mode 100644 index 0000000..2afd2e4 --- /dev/null +++ b/src/main/resources/static/css/art/art-create.css @@ -0,0 +1,284 @@ +.create-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.create-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + border-bottom: 2px solid #8b4992; + padding-bottom: 15px; +} + +.create-header h1 { + color: #8b4992; + margin: 0; + font-size: 2rem; +} + +.back-btn { + background: #6c757d; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 8px; + font-weight: bold; + transition: background-color 0.3s; +} + +.back-btn:hover { + background: #5a6268; +} + +.create-content { + display: grid; + grid-template-columns: 1fr 400px; + gap: 40px; +} + +.canvas-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.canvas-controls { + display: flex; + gap: 30px; + align-items: center; + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + flex-wrap: wrap; +} + +.size-controls, .color-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.size-controls label, .color-controls label { + font-weight: bold; + color: #333; +} + +#canvasSize { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; +} + +#colorPicker { + width: 50px; + height: 40px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.tool-btn { + padding: 8px 16px; + border: 1px solid #8b4992; + background: white; + color: #8b4992; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s; +} + +.tool-btn:hover, .tool-btn.active { + background: #8b4992; + color: white; +} + +.canvas-wrapper { + display: flex; + justify-content: center; + padding: 20px; + background: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +#pixelCanvas { + border-radius: 8px; + cursor: crosshair; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; +} + +.form-section { + background: white; + padding: 24px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + height: fit-content; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: bold; + color: #333; +} + +.form-group input[type="text"], +.form-group textarea { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} + +.form-group textarea { + resize: vertical; + min-height: 100px; +} + +.form-group input[type="checkbox"] { + margin-right: 8px; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.submit-btn { + flex: 1; + padding: 14px 24px; + background: #8b4992; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s; +} + +.submit-btn:hover { + background: #6d3674; +} + +.preview-btn { + padding: 14px 24px; + background: #6c757d; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s; +} + +.preview-btn:hover { + background: #5a6268; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + padding: 24px; + border-radius: 12px; + text-align: center; + max-width: 90%; + max-height: 90%; + overflow: auto; +} + +.modal-content h3 { + margin: 0 0 20px 0; + color: #8b4992; +} + +#previewCanvas { + border: 2px solid #8b4992; + border-radius: 8px; + margin: 16px 0; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; +} + +.close-btn { + padding: 12px 24px; + background: #6c757d; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + margin-top: 16px; +} + +.close-btn:hover { + background: #5a6268; +} + +@media (max-width: 1024px) { + .create-content { + grid-template-columns: 1fr; + gap: 30px; + } + + .canvas-controls { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .size-controls, .color-controls { + justify-content: center; + } +} + +@media (max-width: 768px) { + .create-container { + padding: 16px; + } + + .create-header { + flex-direction: column; + gap: 16px; + text-align: center; + } + + .create-header h1 { + font-size: 1.5rem; + } + + .canvas-controls { + padding: 12px; + } + + .form-actions { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/art/art-detail.css b/src/main/resources/static/css/art/art-detail.css new file mode 100644 index 0000000..0f1bf11 --- /dev/null +++ b/src/main/resources/static/css/art/art-detail.css @@ -0,0 +1,150 @@ +.detail-container { + max-width: 1000px; + margin: 0 auto; + padding: 20px; +} + +.art-section { + background: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + padding: 32px; + margin-bottom: 24px; +} + +.art-canvas-wrapper { + display: flex; + justify-content: center; + align-items: center; + background: #f8f9fa; + border-radius: 8px; + padding: 40px; + margin-bottom: 32px; +} + +#artCanvas { + border: 2px solid #8b4992; + border-radius: 8px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + box-shadow: 0 4px 16px rgba(139, 73, 146, 0.2); +} + +.art-info h1 { + color: #333; + font-size: 2rem; + margin: 0 0 16px 0; + line-height: 1.3; +} + +.art-info p { + color: #666; + font-size: 1.1rem; + line-height: 1.6; + margin: 0 0 24px 0; +} + +.art-meta { + display: flex; + gap: 24px; + flex-wrap: wrap; + padding: 16px 0; + border-top: 1px solid #e9ecef; + border-bottom: 1px solid #e9ecef; + margin-bottom: 24px; +} + +.art-meta span { + font-size: 0.95rem; + color: #666; +} + +.art-meta span strong { + color: #333; + font-weight: 600; +} + +.author { + color: #8b4992 !important; + font-weight: 600 !important; +} + +.art-actions { + display: flex; + gap: 12px; +} + +.action-btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s; +} + +.edit-btn { + background: #8b4992; + color: white; +} + +.edit-btn:hover { + background: #6d3674; +} + +.delete-btn { + background: #dc3545; + color: white; +} + +.delete-btn:hover { + background: #c82333; +} + +.back-section { + text-align: center; +} + +.back-btn { + display: inline-block; + background: #6c757d; + color: white; + padding: 14px 28px; + text-decoration: none; + border-radius: 8px; + font-weight: bold; + transition: background-color 0.3s; +} + +.back-btn:hover { + background: #5a6268; +} + +@media (max-width: 768px) { + .detail-container { + padding: 16px; + } + + .art-section { + padding: 24px 16px; + } + + .art-canvas-wrapper { + padding: 24px 16px; + } + + .art-info h1 { + font-size: 1.5rem; + } + + .art-meta { + flex-direction: column; + gap: 8px; + } + + .art-actions { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/art/art-gallery.css b/src/main/resources/static/css/art/art-gallery.css new file mode 100644 index 0000000..08a701c --- /dev/null +++ b/src/main/resources/static/css/art/art-gallery.css @@ -0,0 +1,349 @@ +/* 데스크톱 스타일 */ +@media (min-width: 769px) { + html, body { + height: 100vh; + width: 100vw; + margin: 0; + display: grid; + justify-items: center; + background: white; + font-family: my-font, sans-serif; + grid-template-rows: 1fr 10fr; + } + + #content-section { + display: grid; + justify-items: center; + align-items: center; + width: 100vw; + height: 100%; + max-height: 100%; + overflow: scroll; + grid-template-columns: 1fr 2fr 1fr; + } + + #main-section { + align-items: center; + justify-items: center; + width: 100%; + height: 100%; + max-height: 100%; + overflow: hidden; + } + + #art-gallery-section { + display: grid; + grid-template-rows: 2fr 8fr; + width: 100%; + height: 100%; + max-height: 100%; + flex-direction: column; + align-items: center; + font-family: my-font, sans-serif; + overflow: hidden; + box-sizing: border-box; + } + + #gallery-header-section { + display: grid; + grid-auto-rows: 3fr 2fr; + text-align: center; + align-items: center; + width: 100%; + max-height: 100%; + overflow: hidden; + box-sizing: border-box; + } + + #gallery-title { + color: darkslateblue; + font-size: 25px; + text-align: center; + } + + #gallery-content { + flex: 1; + width: 100%; + height: 100%; + overflow-y: scroll; + display: flex; + flex-direction: column; + align-items: center; + } + + #create-btn-wrapper { + display: flex; + width: 100%; + justify-content: flex-end; + box-sizing: border-box; + padding: 0 10%; + height: 100%; + max-height: 100%; + overflow: hidden; + } + + #create-art-btn { + padding: 15px; + background: #514897; + color: white; + font-size: 1rem; + text-decoration: none; + border-radius: 8px; + font-family: my-font, sans-serif; + font-weight: bold; + display: block; + } + + #create-art-btn:hover { + background: rgba(72, 61, 139, 0.68); + } + + .art-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 30px; + width: 100%; + max-width: 100%; + padding: 5% 10%; + box-sizing: border-box; + } + + /* 아트 카드 */ + .art-card { + background: white; + border: 2px solid darkslateblue; + border-radius: 12px; + box-sizing: border-box; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; + transition: transform 0.3s, box-shadow 0.3s; + cursor: pointer; + } + + .art-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(139, 73, 146, 0.2); + } + + .art-preview { + position: relative; + background: #f8f9fa; + padding: 25px; + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + border-radius: 12px 12px 0 0; + overflow: hidden; + } + + .art-canvas { + border: 2px solid #e9ecef; + border-radius: 6px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .art-info { + padding: 20px; + } + + .art-title { + font-size: 1.3rem; + font-weight: bold; + color: #333; + margin: 0 0 10px 0; + line-height: 1.4; + font-family: my-font, sans-serif; + } + + .art-description { + color: #666; + font-size: 1rem; + margin: 0 0 15px 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-family: my-font, sans-serif; + } + + .art-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9rem; + color: #999; + font-family: my-font, sans-serif; + } + + .art-author { + font-weight: 500; + color: #8b4992; + } + + #gallery-loading, #gallery-no-more { + text-align: center; + padding: 50px; + color: #666; + font-size: 1.2rem; + font-family: my-font, sans-serif; + width: 100%; + } +} + +/* 모바일 스타일 */ +@media (max-width: 768px) { + #art-gallery-section { + width: 100%; + height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + font-family: my-font, sans-serif; + overflow: hidden; + padding: 16px; + box-sizing: border-box; + } + + #gallery-header-section { + text-align: center; + width: 100%; + margin-bottom: 20px; + flex-shrink: 0; + } + + #gallery-content { + flex: 1; + width: 100%; + overflow-y: auto; + max-height: calc(100% - 150px); + display: flex; + flex-direction: column; + align-items: center; + } + + #gallery-title { + color: darkslateblue; + margin: 0 0 10px 0; + font-size: 1.8rem; + font-family: my-font, sans-serif; + } + + #gallery-subtitle { + color: #666; + margin: 0 0 20px 0; + font-size: 0.9rem; + font-family: my-font, sans-serif; + } + + #create-art-btn { + background: #8b4992; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 8px; + font-family: my-font, sans-serif; + font-weight: bold; + font-size: 1rem; + transition: background-color 0.3s; + display: inline-block; + } + + #create-art-btn:hover { + background: #6d3674; + } + + .art-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 20px; + width: 100%; + padding-bottom: 20px; + } + + /* 아트 카드 */ + .art-card { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + transition: transform 0.3s, box-shadow 0.3s; + cursor: pointer; + } + + .art-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(139, 73, 146, 0.2); + } +} + +/* 모바일 아트 미리보기 및 정보 */ +@media (max-width: 768px) { + .art-preview { + position: relative; + background: #f8f9fa; + padding: 20px; + display: flex; + justify-content: center; + align-items: center; + min-height: 180px; + border-radius: 8px 8px 0 0; + overflow: hidden; + } + + .art-canvas { + border: 1px solid #e9ecef; + border-radius: 4px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + .art-info { + padding: 16px; + } + + .art-title { + font-size: 1.1rem; + font-weight: bold; + color: #333; + margin: 0 0 8px 0; + line-height: 1.4; + font-family: my-font, sans-serif; + } + + .art-description { + color: #666; + font-size: 0.9rem; + margin: 0 0 12px 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-family: my-font, sans-serif; + } + + .art-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + color: #999; + font-family: my-font, sans-serif; + } + + .art-author { + font-weight: 500; + color: #8b4992; + } + +} + + diff --git a/src/main/resources/static/js/art/create.js b/src/main/resources/static/js/art/create.js new file mode 100644 index 0000000..fc9ea92 --- /dev/null +++ b/src/main/resources/static/js/art/create.js @@ -0,0 +1,197 @@ +import * as util from "../common.js"; + +const CONFIG = { + gridSize: 30, + cellSize: 24, + gap: 6, + corner: 6, + colors: { + base: "#6633cc", + hover: "#7c4dff", + active: "#cbb5ff", + activeHover: "#e4daff", + border: "rgba(255,255,255,0.55)", + shadow: "rgba(85,34,170,0.25)", + shadowActive: "rgba(161,120,255,0.35)", + background: "#12082a" + } +}; + +class GrapePalette { + constructor({ canvasId, resetId, formId }) { + this.canvas = document.getElementById(canvasId); + if (!this.canvas) return; + + this.ctx = this.canvas.getContext("2d"); + this.resetBtn = document.getElementById(resetId); + this.form = document.getElementById(formId); + + this.gridSize = CONFIG.gridSize; + this.cellSize = CONFIG.cellSize; + this.gap = CONFIG.gap; + this.corner = CONFIG.corner; + this.innerSize = this.cellSize - this.gap; + + this.canvas.width = this.gridSize * this.cellSize; + this.canvas.height = this.gridSize * this.cellSize; + this.canvas.style.touchAction = "manipulation"; + + this.state = new Array(this.gridSize * this.gridSize).fill(false); + this.hoverIndex = null; + + this.bindEvents(); + this.drawBoard(); + } + + bindEvents() { + this.canvas.addEventListener("pointermove", (e) => this.handleHover(e)); + this.canvas.addEventListener("pointerleave", () => this.clearHover()); + this.canvas.addEventListener("click", (e) => this.handleToggle(e)); + + this.resetBtn?.addEventListener("click", () => this.reset()); + this.form?.addEventListener("submit", (e) => this.handleSubmit(e)); + } + + handleHover(event) { + const index = this.eventToIndex(event); + if (index === this.hoverIndex) return; + this.hoverIndex = index; + this.drawBoard(); + } + + clearHover() { + if (this.hoverIndex === null) return; + this.hoverIndex = null; + this.drawBoard(); + } + + handleToggle(event) { + const index = this.eventToIndex(event); + if (index === null) return; + this.state[index] = !this.state[index]; + this.drawCell(index); + } + + eventToIndex(event) { + const rect = this.canvas.getBoundingClientRect(); + const clientX = event.clientX ?? event.touches?.[0]?.clientX; + const clientY = event.clientY ?? event.touches?.[0]?.clientY; + + const x = Math.floor((clientX - rect.left) / this.cellSize); + const y = Math.floor((clientY - rect.top) / this.cellSize); + + if (Number.isNaN(x) || Number.isNaN(y)) return null; + if (x < 0 || x >= this.gridSize || y < 0 || y >= this.gridSize) return null; + + return y * this.gridSize + x; + } + + drawBoard() { + this.ctx.fillStyle = CONFIG.colors.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.state.forEach((_, index) => this.drawCell(index)); + } + + drawCell(index) { + const { x, y } = this.indexToPoint(index); + const isActive = this.state[index]; + const isHover = index === this.hoverIndex; + + const color = isActive + ? (isHover ? CONFIG.colors.activeHover : CONFIG.colors.active) + : (isHover ? CONFIG.colors.hover : CONFIG.colors.base); + + const drawX = x + this.gap / 2; + const drawY = y + this.gap / 2; + const size = this.innerSize; + const radius = Math.min(this.corner, size / 2); + + this.ctx.save(); + this.ctx.beginPath(); + this.roundedRectPath(drawX, drawY, size, size, radius); + this.ctx.fillStyle = color; + this.ctx.shadowColor = isActive ? CONFIG.colors.shadowActive : CONFIG.colors.shadow; + this.ctx.shadowBlur = isActive ? 14 : 8; + this.ctx.fill(); + + if (isHover) { + this.ctx.lineWidth = 1.5; + this.ctx.strokeStyle = CONFIG.colors.border; + this.ctx.stroke(); + } + + this.ctx.restore(); + } + + roundedRectPath(x, y, width, height, radius) { + const ctx = this.ctx; + ctx.moveTo(x + radius, y); + ctx.arcTo(x + width, y, x + width, y + height, radius); + ctx.arcTo(x + width, y + height, x, y + height, radius); + ctx.arcTo(x, y + height, x, y, radius); + ctx.arcTo(x, y, x + width, y, radius); + ctx.closePath(); + } + + indexToPoint(index) { + const col = index % this.gridSize; + const row = Math.floor(index / this.gridSize); + return { x: col * this.cellSize, y: row * this.cellSize }; + } + + reset() { + this.state.fill(false); + this.drawBoard(); + } + + toPixelData() { + return this.state.map(active => active ? '1' : '0').join(''); + } + + async handleSubmit(event) { + if (!this.form) return; + event.preventDefault(); + + const title = this.form.querySelector("#title")?.value.trim(); + const description = this.form.querySelector("#description")?.value.trim(); + const isPublic = this.form.querySelector("#isPublic")?.checked ?? false; + + if (!title || !description) { + alert("제목과 설명을 모두 입력해주세요."); + return; + } + + const artData = { + title, + description, + pixel_data: this.toPixelData(), + width: this.gridSize, + height: this.gridSize, + is_public: isPublic + }; + + try { + const response = await util.authFetch(`${window.location.origin}/api/arts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(artData), + credentials: "same-origin" + }); + + if (!response.ok) throw new Error(await response.text()); + const result = await response.json(); + window.location.href = `/art/${result.id}`; + } catch (error) { + console.error("작품 등록 실패:", error); + alert("작품 등록에 실패했습니다. 다시 시도해주세요."); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + new GrapePalette({ + canvasId: "pixelCanvas", + resetId: "clearBtn", + formId: "artForm" + }); +}); diff --git a/src/main/resources/static/js/art/detail.js b/src/main/resources/static/js/art/detail.js new file mode 100644 index 0000000..5f1688a --- /dev/null +++ b/src/main/resources/static/js/art/detail.js @@ -0,0 +1,288 @@ +class Detail { + constructor() { + this.artId = ART_ID; + this.artData = null; + + this.titleElement = document.getElementById('artTitle'); + this.descriptionElement = document.getElementById('artDescription'); + this.authorElement = document.getElementById('authorName'); + this.createdAtElement = document.getElementById('createdAt'); + this.viewCountElement = document.getElementById('viewCount'); + this.artCanvas = document.getElementById('artCanvas'); + this.artActions = document.getElementById('artActions'); + this.editBtn = document.getElementById('editBtn'); + this.deleteBtn = document.getElementById('deleteBtn'); + + this.init(); + } + + async init() { + await this.loadArt(); + this.setupEventListeners(); + } + + async loadArt() { + try { + const response = await fetch(`${HOST}/api/arts/${this.artId}`, { + credentials: 'include' + }); + + if (response.ok) { + this.artData = await response.json(); + this.renderArt(); + this.checkOwnership(); + } else { + throw new Error('작품을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('작품 로딩 실패:', error); + alert('작품을 불러오는데 실패했습니다.'); + window.location.href = '/art'; + } + } + + renderArt() { + const { title, description, author_name, created_at, view_count, pixel_data, width, height } = this.artData; + + // 기본 정보 표시 + this.titleElement.textContent = title; + this.descriptionElement.textContent = description; + this.authorElement.textContent = author_name; + this.createdAtElement.textContent = this.formatDate(created_at); + this.viewCountElement.textContent = view_count.toLocaleString(); + + // 캔버스 설정 및 렌더링 + const pixelSize = Math.max(8, Math.min(20, Math.floor(400 / Math.max(width, height)))); + this.artCanvas.width = width * pixelSize; + this.artCanvas.height = height * pixelSize; + + this.renderPixelArt(this.artCanvas, pixel_data, pixelSize); + } + + renderPixelArt(canvas, pixelData, pixelSize) { + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + + // 배경을 부드러운 그라데이션으로 + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, '#f8fafc'); + gradient.addColorStop(1, '#e2e8f0'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + if (!pixelData || !Array.isArray(pixelData)) { + return; + } + + const seatSize = pixelSize * 0.7; // 좌석 크기 (여백 포함) + const seatGap = pixelSize * 0.3; // 좌석 간 여백 + + for (let y = 0; y < pixelData.length; y++) { + for (let x = 0; x < pixelData[y].length; x++) { + const isSelected = pixelData[y][x]; + const seatX = x * pixelSize + seatGap / 2; + const seatY = y * pixelSize + seatGap / 2; + + if (isSelected === true) { + // 선택된 좌석 (모던한 보라색) + this.drawModernSeat(ctx, seatX, seatY, seatSize, true, false); + } else { + // 매진 좌석 (모던한 회색) + this.drawModernSeat(ctx, seatX, seatY, seatSize, false, false); + } + } + } + } + + drawModernSeat(ctx, x, y, size, isSelected, isHovered) { + const seatSize = size * 0.9; + const offset = (size - seatSize) / 2; + const radius = seatSize * 0.25; + + // 그림자 효과 + ctx.shadowColor = 'rgba(0, 0, 0, 0.1)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 2; + + if (isSelected) { + // 선택된 좌석 - 단색 + 테두리 + ctx.fillStyle = '#e11d48'; // 로즈 핑크 + this.drawRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + + // 테두리 추가 + ctx.shadowColor = 'transparent'; + ctx.strokeStyle = '#9f1239'; // 더 진한 색상으로 테두리 + ctx.lineWidth = 2; + this.strokeRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + + } else { + // 매진 좌석 - 단색 + 테두리 + ctx.fillStyle = '#64748b'; // 그레이 + this.drawRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + + // 테두리 추가 + ctx.shadowColor = 'transparent'; + ctx.strokeStyle = '#334155'; // 더 진한 그레이로 테두리 + ctx.lineWidth = 1.5; + this.strokeRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + } + + // 그림자 리셋 + ctx.shadowColor = 'transparent'; + } + + drawSeat(ctx, x, y, size, isOccupied) { + const seatSize = size * 0.85; + const offset = (size - seatSize) / 2; + const radius = seatSize * 0.2; + + if (isOccupied) { + // 선택된 좌석 - 색상이 있는 좌석 + ctx.fillStyle = ctx.fillStyle; + this.drawRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + + // 테두리 추가 + ctx.strokeStyle = this.darkenColor(ctx.fillStyle, 0.2); + ctx.lineWidth = 2; + this.strokeRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + } else { + // 빈 좌석 - 밝은 배경에 테두리 + ctx.fillStyle = '#f8f9fa'; + this.drawRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + + ctx.strokeStyle = '#dee2e6'; + ctx.lineWidth = 1.5; + this.strokeRoundedRect(ctx, x + offset, y + offset, seatSize, seatSize, radius); + } + } + + drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + } + + strokeRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.stroke(); + } + + darkenColor(color, factor) { + // RGB 색상을 어둡게 만드는 함수 + if (color.startsWith('#')) { + const r = parseInt(color.substr(1, 2), 16); + const g = parseInt(color.substr(3, 2), 16); + const b = parseInt(color.substr(5, 2), 16); + + return `rgb(${Math.floor(r * (1 - factor))}, ${Math.floor(g * (1 - factor))}, ${Math.floor(b * (1 - factor))})`; + } + return color; + } + + async checkOwnership() { + try { + const response = await fetch(`${HOST}/api/arts/my?page=0&size=1`, { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + const isOwner = data.content.some(art => art.id === parseInt(this.artId)); + + if (isOwner) { + this.artActions.style.display = 'block'; + } + } + } catch (error) { + console.error('소유권 확인 실패:', error); + } + } + + setupEventListeners() { + if (this.editBtn) { + this.editBtn.addEventListener('click', () => this.editArt()); + } + + if (this.deleteBtn) { + this.deleteBtn.addEventListener('click', () => this.deleteArt()); + } + } + + editArt() { + const title = prompt('새 제목을 입력하세요:', this.artData.title); + if (title === null) return; + + const description = prompt('새 설명을 입력하세요:', this.artData.description); + if (description === null) return; + + const isPublic = confirm('공개 작품으로 설정하시겠습니까?'); + + this.updateArt({ title: title.trim(), description: description.trim(), is_public: isPublic }); + } + + async updateArt(updateData) { + try { + const response = await fetch(`${HOST}/api/arts/${this.artId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(updateData) + }); + + if (response.ok) { + const updatedArt = await response.json(); + this.artData = updatedArt; + this.renderArt(); + alert('작품이 성공적으로 수정되었습니다.'); + } else { + throw new Error('작품 수정에 실패했습니다.'); + } + } catch (error) { + console.error('작품 수정 실패:', error); + alert('작품 수정에 실패했습니다. 다시 시도해주세요.'); + } + } + + async deleteArt() { + if (!confirm('정말로 이 작품을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) { + return; + } + + try { + const response = await fetch(`${HOST}/api/arts/${this.artId}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (response.ok) { + alert('작품이 성공적으로 삭제되었습니다.'); + window.location.href = '/art'; + } else { + throw new Error('작품 삭제에 실패했습니다.'); + } + } catch (error) { + console.error('작품 삭제 실패:', error); + alert('작품 삭제에 실패했습니다. 다시 시도해주세요.'); + } + } + + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } +} + +// 페이지 로드 시 상세 페이지 초기화 +document.addEventListener('DOMContentLoaded', () => { + new Detail(); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/art/gallery.js b/src/main/resources/static/js/art/gallery.js new file mode 100644 index 0000000..9d6b678 --- /dev/null +++ b/src/main/resources/static/js/art/gallery.js @@ -0,0 +1,232 @@ +const PIXEL_THEME = { + gridSize: 30, + cellSize: 24, + gap: 6, + corner: 6, + previewScale: 12, + colors: { + background: "#12082a", + base: "#6633cc", + hover: "#7c4dff", + active: "#cbb5ff", + activeHover: "#e4daff", + border: "rgba(255,255,255,0.55)", + shadow: "rgba(85,34,170,0.25)", + shadowActive: "rgba(161,120,255,0.35)" + } +}; + +class Gallery { + constructor() { + this.currentPage = 0; + this.size = 20; + this.hasMore = true; + this.galleryContainer = document.getElementById("gallery-grid"); + this.init(); + } + + init() { + this.loadArts(); + this.setupInfiniteScroll(); + } + + async loadArts() { + if (!this.hasMore) { + return; + } + try { + const response = await fetch(`${HOST}/api/arts?page=${this.currentPage}&size=${this.size}`); + const data = await response.json(); + + if (data.content && data.content.length > 0) { + this.renderArts(data.content); + this.currentPage++; + this.hasMore = !data.last; + } else { + this.hasMore = false; + } + } catch (error) { + console.error("failed to load arts", error); + alert("작품을 불러오는데 실패했습니다."); + } + } + + renderArts(arts) { + arts.forEach((art) => { + const artCard = this.createArtCard(art); + this.galleryContainer.appendChild(artCard); + }); + } + + createArtCard(art) { + const card = document.createElement("div"); + card.className = "art-card"; + card.onclick = () => { + window.location.href = `/art/${art.id}`; + }; + + const artPreview = document.createElement("div"); + artPreview.className = "art-preview"; + + const canvas = document.createElement("canvas"); + canvas.className = "art-canvas"; + + const pixelSize = PIXEL_THEME.previewScale; + const width = art.width ?? PIXEL_THEME.gridSize; + const height = art.height ?? PIXEL_THEME.gridSize; + canvas.width = width * pixelSize; + canvas.height = height * pixelSize; + + this.renderPixelArt(canvas, art.pixel_data, width, height, pixelSize); + artPreview.appendChild(canvas); + + const artInfo = document.createElement("div"); + artInfo.className = "art-info"; + artInfo.innerHTML = ` +

${art.title}

+

${this.escapeHtml(art.description)}

+
+ 작가명 : ${art.author_name} + ${this.formatDate(art.created_at)} +
+ `; + + card.appendChild(artPreview); + card.appendChild(artInfo); + + return card; + } + + renderPixelArt(canvas, rawPixelData, width, height, pixelSize = 1) { + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + ctx.fillStyle = PIXEL_THEME.colors.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const pixels = this.normalizePixelData(rawPixelData, width, height); + if (!pixels.length) return; + + const gapRatio = PIXEL_THEME.gap / PIXEL_THEME.cellSize; + const cornerRatio = PIXEL_THEME.corner / PIXEL_THEME.cellSize; + const gap = pixelSize * gapRatio; + const innerSize = pixelSize - gap; + const radius = Math.min(pixelSize * cornerRatio, innerSize / 2); + + pixels.forEach((isActive, index) => { + const col = index % width; + const row = Math.floor(index / width); + const x = col * pixelSize + gap / 2; + const y = row * pixelSize + gap / 2; + + ctx.save(); + ctx.beginPath(); + this.roundedRectPath(ctx, x, y, innerSize, innerSize, radius); + ctx.fillStyle = isActive ? PIXEL_THEME.colors.active : PIXEL_THEME.colors.base; + ctx.shadowColor = isActive ? PIXEL_THEME.colors.shadowActive : PIXEL_THEME.colors.shadow; + ctx.shadowBlur = isActive ? 14 : 8; + ctx.fill(); + ctx.restore(); + }); + } + + normalizePixelData(pixelData, width, height) { + if (pixelData == null) return []; + + const total = width * height; + const coerceBool = (value) => { + if (value === true || value === 1) return true; + if (value === false || value === 0) return false; + if (typeof value === "string") { + const lowered = value.trim().toLowerCase(); + if (lowered === "true" || lowered === "1") return true; + if (lowered === "false" || lowered === "0") return false; + } + return false; + }; + + if (typeof pixelData === "string") { + const trimmed = pixelData.trim(); + const compactDigits = trimmed.replace(/[^01]/g, ""); + + if (compactDigits.length > 0 && /^[01]+$/.test(compactDigits)) { + return this.fillToSize([...compactDigits].map((digit) => digit === "1"), total); + } + + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return this.normalizePixelData(parsed, width, height); + } + } catch (error) { + // fallback to token parsing + } + } + + const tokens = trimmed.includes(",") + ? trimmed.split(/[\s,]+/).filter(Boolean) + : trimmed.split(""); + + const mapped = tokens.map((token) => { + const lowered = token.trim().toLowerCase(); + if (lowered === "1" || lowered === "true") return true; + if (lowered === "0" || lowered === "false") return false; + return false; + }); + return this.fillToSize(mapped, total); + } + + if (Array.isArray(pixelData)) { + const flattened = pixelData.flat(Infinity); + const mapped = flattened.map((value) => coerceBool(value)); + return this.fillToSize(mapped, total); + } + + return []; + } + + fillToSize(pixels, total) { + if (pixels.length >= total) { + return pixels.slice(0, total); + } + const padding = Array(total - pixels.length).fill(false); + return pixels.concat(padding); + } + + roundedRectPath(ctx, x, y, width, height, radius) { + ctx.moveTo(x + radius, y); + ctx.arcTo(x + width, y, x + width, y + height, radius); + ctx.arcTo(x + width, y + height, x, y + height, radius); + ctx.arcTo(x, y + height, x, y, radius); + ctx.arcTo(x, y, x + width, y, radius); + ctx.closePath(); + } + + setupInfiniteScroll() { + window.addEventListener("scroll", () => { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) { + this.loadArts(); + } + }); + } + + escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text ?? ""; + return div.innerHTML; + } + + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "short", + day: "numeric" + }); + } +} + +document.addEventListener("DOMContentLoaded", () => { + new Gallery(); +}); diff --git a/src/main/resources/static/js/header.js b/src/main/resources/static/js/header.js index 79e4160..5417d64 100644 --- a/src/main/resources/static/js/header.js +++ b/src/main/resources/static/js/header.js @@ -6,14 +6,16 @@ const mobilePageInfos = { ticketing: document.getElementById("ticketing-page-btn"), rank: document.getElementById("rank-page-btn"), security: document.getElementById("security-page-btn"), - blog: document.getElementById("blog-page-btn") + blog: document.getElementById("blog-page-btn"), + art: document.getElementById("art-page-btn") } const desktopPageInfos = { ticketing: document.getElementById("ticketing-desktop-btn"), rank: document.getElementById("rank-desktop-btn"), security: document.getElementById("security-desktop-btn"), - blog: document.getElementById("blog-desktop-btn") + blog: document.getElementById("blog-desktop-btn"), + art: document.getElementById("art-desktop-btn") } async function displayNickName() { diff --git a/src/main/resources/templates/art/create.html b/src/main/resources/templates/art/create.html new file mode 100644 index 0000000..45941e2 --- /dev/null +++ b/src/main/resources/templates/art/create.html @@ -0,0 +1,76 @@ + + + + + + 작품 만들기 - 프랙티켓 + + + + + +
+ +
+
+

픽셀 아트 만들기

+ 갤러리로 돌아가기 +
+ +
+
+
+
+ 포도알로 여러분의 팬아트를 보여주세요. +
+
+ +
+
+ +
+ +
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/art/detail.html b/src/main/resources/templates/art/detail.html new file mode 100644 index 0000000..909644d --- /dev/null +++ b/src/main/resources/templates/art/detail.html @@ -0,0 +1,49 @@ + + + + + + 작품 상세 - 프랙티켓 + + + + + +
+ +
+
+
+ +
+ +
+

작품 제목

+

작품 설명

+ +
+ 작성자: 익명 + 작성일: + 조회수: 0 +
+ + +
+
+ + +
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/art/gallery.html b/src/main/resources/templates/art/gallery.html new file mode 100644 index 0000000..c110c02 --- /dev/null +++ b/src/main/resources/templates/art/gallery.html @@ -0,0 +1,37 @@ + + + + + + + + 포도아트 - 프랙티켓 + + + + + + +
+
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 8ccb3bc..0a35eb3 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -9,6 +9,7 @@ 순위표 보안문자 블로그 + 팬아트 From 7a4061677385e63af8411a90ac1e6c3de131e837 Mon Sep 17 00:00:00 2001 From: jeongho Date: Sat, 4 Oct 2025 11:10:49 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=ED=8C=AC=EC=95=84=ED=8A=B8=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../css/art/{art-create.css => create.css} | 0 .../css/art/{art-detail.css => detail.css} | 0 .../css/art/{art-gallery.css => gallery.css} | 277 ++++++++++++------ src/main/resources/static/js/art/gallery.js | 201 ++++++++----- src/main/resources/templates/art/create.html | 2 +- src/main/resources/templates/art/detail.html | 2 +- src/main/resources/templates/art/gallery.html | 2 +- 7 files changed, 324 insertions(+), 160 deletions(-) rename src/main/resources/static/css/art/{art-create.css => create.css} (100%) rename src/main/resources/static/css/art/{art-detail.css => detail.css} (100%) rename src/main/resources/static/css/art/{art-gallery.css => gallery.css} (60%) diff --git a/src/main/resources/static/css/art/art-create.css b/src/main/resources/static/css/art/create.css similarity index 100% rename from src/main/resources/static/css/art/art-create.css rename to src/main/resources/static/css/art/create.css diff --git a/src/main/resources/static/css/art/art-detail.css b/src/main/resources/static/css/art/detail.css similarity index 100% rename from src/main/resources/static/css/art/art-detail.css rename to src/main/resources/static/css/art/detail.css diff --git a/src/main/resources/static/css/art/art-gallery.css b/src/main/resources/static/css/art/gallery.css similarity index 60% rename from src/main/resources/static/css/art/art-gallery.css rename to src/main/resources/static/css/art/gallery.css index 08a701c..6aa6f2c 100644 --- a/src/main/resources/static/css/art/art-gallery.css +++ b/src/main/resources/static/css/art/gallery.css @@ -1,3 +1,12 @@ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); + +/* 공통 */ +.art-stats-number { + font-family: Pretendard, sans-serif; + color: #333537; +} + + /* 데스크톱 스타일 */ @media (min-width: 769px) { html, body { @@ -64,7 +73,7 @@ #gallery-content { flex: 1; width: 100%; - height: 100%; + height: 90%; overflow-y: scroll; display: flex; flex-direction: column; @@ -100,89 +109,130 @@ .art-gallery-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 30px; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: 40px; width: 100%; - max-width: 100%; - padding: 5% 10%; + padding: 5%; box-sizing: border-box; + align-items: center; + justify-items: center; } - /* 아트 카드 */ + /* 아트 카드 - 인스타 스타일 */ .art-card { - background: white; - border: 2px solid darkslateblue; - border-radius: 12px; + display: flex; + flex-direction: column; + background: none; + border: 1px solid #dbdbdb; + border-radius: 16px; box-sizing: border-box; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); overflow: hidden; - transition: transform 0.3s, box-shadow 0.3s; - cursor: pointer; + max-width: 500px; } .art-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 24px rgba(139, 73, 146, 0.2); + transform: scale(1.01); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); } .art-preview { position: relative; - background: #f8f9fa; - padding: 25px; + background: #fafafa; display: flex; justify-content: center; align-items: center; - min-height: 200px; - border-radius: 12px 12px 0 0; + aspect-ratio: 1; overflow: hidden; + border: 1px solid #dbdbdb; + cursor: pointer; + } + + .art-caption, .art-footer { + cursor: pointer; } .art-canvas { - border: 2px solid #e9ecef; - border-radius: 6px; + width: 100%; + height: 100%; + object-fit: contain; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .art-info { - padding: 20px; + padding: 10px 14px; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + align-items: center; + justify-items: left; + row-gap: 10%; } - .art-title { - font-size: 1.3rem; - font-weight: bold; - color: #333; - margin: 0 0 10px 0; - line-height: 1.4; - font-family: my-font, sans-serif; + .art-stats { + display: flex; + gap: 10%; } - .art-description { - color: #666; - font-size: 1rem; - margin: 0 0 15px 0; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; + .art-likes, .art-comments, .art-views { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + font-weight: 600; + color: #262626; font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s ease; + } + + .art-views { + cursor: default; + } + + .art-likes svg path { + transition: all 0.2s ease; + } + + .art-likes:hover svg path { + fill: #ed4956 !important; + stroke: #ed4956 !important; + } + + .art-comments svg, .art-views svg { + fill: none; + stroke: currentColor; } - .art-meta { + .art-caption { + width: 100%; display: flex; - justify-content: space-between; align-items: center; - font-size: 0.9rem; - color: #999; - font-family: my-font, sans-serif; + margin: 0; + overflow: hidden; + gap: 3%; + font-family: Pretendard, sans-serif; } .art-author { - font-weight: 500; - color: #8b4992; + font-weight: bold; + font-size: 1.1rem; + color: rgb(28, 27, 32); + } + + .art-title { + color: rgb(28, 27, 32); + font-size: 0.9rem; + font-weight: 400; + } + + .art-date { + font-size: 0.75rem; + color: #8e8e8e; + font-family: my-font, sans-serif; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-end; } #gallery-loading, #gallery-no-more { @@ -260,90 +310,143 @@ .art-gallery-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 20px; + grid-template-columns: 1fr; + gap: 24px; width: 100%; - padding-bottom: 20px; + max-width: 600px; + padding: 16px; } - /* 아트 카드 */ + /* 아트 카드 - 인스타 모바일 */ .art-card { background: white; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border: 1px solid #dbdbdb; + border-radius: 12px; overflow: hidden; - transition: transform 0.3s, box-shadow 0.3s; - cursor: pointer; + transition: all 0.2s ease; } .art-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(139, 73, 146, 0.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + } + + .art-preview { + cursor: pointer; + } + + .art-caption, .art-footer { + cursor: pointer; } -} -/* 모바일 아트 미리보기 및 정보 */ -@media (max-width: 768px) { .art-preview { position: relative; - background: #f8f9fa; - padding: 20px; + background: #fafafa; + padding: 0; display: flex; justify-content: center; align-items: center; - min-height: 180px; - border-radius: 8px 8px 0 0; + aspect-ratio: 1; overflow: hidden; } .art-canvas { - border: 1px solid #e9ecef; - border-radius: 4px; + width: 100%; + height: 100%; + object-fit: contain; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .art-info { - padding: 16px; + padding: 10px 14px 14px; + border-top: 1px solid #efefef; } - .art-title { - font-size: 1.1rem; - font-weight: bold; - color: #333; - margin: 0 0 8px 0; + .art-stats { + display: flex; + gap: 14px; + margin-bottom: 10px; + } + + .art-likes, .art-comments, .art-views { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.85rem; + font-weight: 600; + color: #262626; + font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s ease; + } + + .art-views { + cursor: default; + } + + .art-likes svg path { + transition: all 0.2s ease; + } + + .art-likes:hover svg path { + fill: #ed4956 !important; + stroke: #ed4956 !important; + } + + .art-comments svg, .art-views svg { + fill: none; + stroke: currentColor; + } + + .art-caption { + font-size: 0.85rem; + color: #262626; + margin: 0 0 5px 0; line-height: 1.4; font-family: my-font, sans-serif; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .art-author { + font-weight: 700; + color: #262626; + } + + .art-title { + font-weight: 400; + color: #262626; + } + + .art-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 6px; } .art-description { - color: #666; - font-size: 0.9rem; - margin: 0 0 12px 0; + color: #8e8e8e; + font-size: 0.8rem; + margin: 0; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-family: my-font, sans-serif; + flex: 1; } - .art-meta { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - color: #999; + .art-date { + font-size: 0.7rem; + color: #8e8e8e; font-family: my-font, sans-serif; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-end; } - - .art-author { - font-weight: 500; - color: #8b4992; - } - } - diff --git a/src/main/resources/static/js/art/gallery.js b/src/main/resources/static/js/art/gallery.js index 9d6b678..14ee604 100644 --- a/src/main/resources/static/js/art/gallery.js +++ b/src/main/resources/static/js/art/gallery.js @@ -1,3 +1,5 @@ +import * as util from "../common.js"; + const PIXEL_THEME = { gridSize: 30, cellSize: 24, @@ -5,7 +7,7 @@ const PIXEL_THEME = { corner: 6, previewScale: 12, colors: { - background: "#12082a", + background: "#281d43", base: "#6633cc", hover: "#7c4dff", active: "#cbb5ff", @@ -30,12 +32,20 @@ class Gallery { this.setupInfiniteScroll(); } + setupInfiniteScroll() { + window.addEventListener("scroll", () => { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) { + this.loadArts(); + } + }); + } + async loadArts() { if (!this.hasMore) { return; } try { - const response = await fetch(`${HOST}/api/arts?page=${this.currentPage}&size=${this.size}`); + const response = await util.authFetch(`${HOST}/api/arts?page=${this.currentPage}&size=${this.size}`); const data = await response.json(); if (data.content && data.content.length > 0) { @@ -61,12 +71,12 @@ class Gallery { createArtCard(art) { const card = document.createElement("div"); card.className = "art-card"; - card.onclick = () => { - window.location.href = `/art/${art.id}`; - }; const artPreview = document.createElement("div"); artPreview.className = "art-preview"; + artPreview.onclick = () => { + window.location.href = `/art/${art.id}`; + }; const canvas = document.createElement("canvas"); canvas.className = "art-canvas"; @@ -82,28 +92,101 @@ class Gallery { const artInfo = document.createElement("div"); artInfo.className = "art-info"; + + const isLiked = art.is_liked_by_current_user || false; + const likeIconPath = 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41 0.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'; + + const likeFill = isLiked ? '#ed4956' : 'none'; + const likeStroke = isLiked ? '#ed4956' : 'currentColor'; + artInfo.innerHTML = ` -

${art.title}

-

${this.escapeHtml(art.description)}

-
- 작가명 : ${art.author_name} - ${this.formatDate(art.created_at)} +
+
+ + + + + +
+
+ + + + ${this.formatCount(art.comment_count || 0)} +
+
+ + + + + ${this.formatCount(art.view_count || 0)} +
+
+
+ ${art.author_name} + ${art.title} +
+
+ ${this.formatDate(art.created_at)}
`; + // 좋아요 버튼 이벤트 리스너 + const likeBtn = artInfo.querySelector(".art-likes"); + likeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleLike(art.id, likeBtn); + }); + card.appendChild(artPreview); card.appendChild(artInfo); return card; } + async toggleLike(artId, likeBtn) { + try { + const response = await util.authFetch(`${HOST}/api/arts/${artId}/like`, { + method: "POST", + credentials: "same-origin" + }); + + if (!response.ok) throw new Error("좋아요 실패"); + + const result = await response.json(); + const isLiked = result.is_liked; + const likeCount = result.like_count; + + // UI 업데이트 + const svgPath = likeBtn.querySelector("svg path"); + const countSpan = likeBtn.querySelector(".like-count"); + + if (isLiked) { + likeBtn.classList.add("liked"); + svgPath.setAttribute("fill", "#ed4956"); + svgPath.setAttribute("stroke", "#ed4956"); + } else { + likeBtn.classList.remove("liked"); + svgPath.setAttribute("fill", "none"); + svgPath.setAttribute("stroke", "currentColor"); + } + + countSpan.textContent = this.formatCount(likeCount); + } catch (error) { + console.error("좋아요 토글 실패:", error); + alert("좋아요 처리에 실패했습니다."); + } + } + renderPixelArt(canvas, rawPixelData, width, height, pixelSize = 1) { const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; + // 배경 작업 ctx.fillStyle = PIXEL_THEME.colors.background; ctx.fillRect(0, 0, canvas.width, canvas.height); + // 문자열 데이터 가공 -> boolean 배열 반환 const pixels = this.normalizePixelData(rawPixelData, width, height); if (!pixels.length) return; @@ -131,61 +214,16 @@ class Gallery { } normalizePixelData(pixelData, width, height) { - if (pixelData == null) return []; + if (!pixelData || typeof pixelData !== "string") return []; const total = width * height; - const coerceBool = (value) => { - if (value === true || value === 1) return true; - if (value === false || value === 0) return false; - if (typeof value === "string") { - const lowered = value.trim().toLowerCase(); - if (lowered === "true" || lowered === "1") return true; - if (lowered === "false" || lowered === "0") return false; - } - return false; - }; - - if (typeof pixelData === "string") { - const trimmed = pixelData.trim(); - const compactDigits = trimmed.replace(/[^01]/g, ""); - - if (compactDigits.length > 0 && /^[01]+$/.test(compactDigits)) { - return this.fillToSize([...compactDigits].map((digit) => digit === "1"), total); - } - - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return this.normalizePixelData(parsed, width, height); - } - } catch (error) { - // fallback to token parsing - } - } + const cleaned = pixelData.replace(/[^01]/g, ""); + const pixels = [...cleaned].map((digit) => digit === "1"); - const tokens = trimmed.includes(",") - ? trimmed.split(/[\s,]+/).filter(Boolean) - : trimmed.split(""); - - const mapped = tokens.map((token) => { - const lowered = token.trim().toLowerCase(); - if (lowered === "1" || lowered === "true") return true; - if (lowered === "0" || lowered === "false") return false; - return false; - }); - return this.fillToSize(mapped, total); - } - - if (Array.isArray(pixelData)) { - const flattened = pixelData.flat(Infinity); - const mapped = flattened.map((value) => coerceBool(value)); - return this.fillToSize(mapped, total); - } - - return []; + return this.fillToSize(pixels, total); } + // 픽셀 데이터가 canvas 사이즈에 맞지 않은 예외 케이스 처리 fillToSize(pixels, total) { if (pixels.length >= total) { return pixels.slice(0, total); @@ -203,14 +241,6 @@ class Gallery { ctx.closePath(); } - setupInfiniteScroll() { - window.addEventListener("scroll", () => { - if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) { - this.loadArts(); - } - }); - } - escapeHtml(text) { const div = document.createElement("div"); div.textContent = text ?? ""; @@ -219,11 +249,42 @@ class Gallery { formatDate(dateString) { const date = new Date(dateString); + const now = new Date(); + + const diffMs = now - date; // 시간 차 (ms) + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + + // 오늘 하루 안에 속하는 경우 + if ( + now.toDateString() === date.toDateString() // 같은 날인지 비교 + ) { + if (diffSec < 60) { + return `${diffSec}초 전`; + } else if (diffMin < 60) { + return `${diffMin}분 전`; + } else { + return `${diffHour}시간 전`; + } + } + + // 하루 이상 차이나면 날짜로 표시 return date.toLocaleDateString("ko-KR", { year: "numeric", month: "short", - day: "numeric" - }); + day: "numeric", + });; + } + + formatCount(count) { + if (count >= 1000000) { + return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'm'; + } + if (count >= 1000) { + return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + } + return count.toString(); } } diff --git a/src/main/resources/templates/art/create.html b/src/main/resources/templates/art/create.html index 45941e2..4220b1f 100644 --- a/src/main/resources/templates/art/create.html +++ b/src/main/resources/templates/art/create.html @@ -6,7 +6,7 @@ 작품 만들기 - 프랙티켓 - +
diff --git a/src/main/resources/templates/art/detail.html b/src/main/resources/templates/art/detail.html index 909644d..763522e 100644 --- a/src/main/resources/templates/art/detail.html +++ b/src/main/resources/templates/art/detail.html @@ -6,7 +6,7 @@ 작품 상세 - 프랙티켓 - +
diff --git a/src/main/resources/templates/art/gallery.html b/src/main/resources/templates/art/gallery.html index c110c02..d1991b6 100644 --- a/src/main/resources/templates/art/gallery.html +++ b/src/main/resources/templates/art/gallery.html @@ -9,7 +9,7 @@ - +
From 0bb8e5244a09f869a386acec616c4038825f2101 Mon Sep 17 00:00:00 2001 From: jeongho Date: Sun, 5 Oct 2025 14:52:10 +0900 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20=ED=8C=AC=EC=95=84=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../art/application/ArtController.java | 38 +- .../ticketing/art/application/ArtService.java | 100 +++- .../com/example/ticketing/art/domain/Art.java | 24 +- .../art/domain/ArtQueryCondition.java | 16 + .../ticketing/art/domain/ArtRepository.java | 33 +- .../art/domain/ArtRepositoryCustom.java | 8 + .../art/domain/ArtRepositoryImpl.java | 83 +++ .../ticketing/art/dto/ArtCreateRequest.java | 8 +- .../ticketing/art/dto/ArtLikeResponse.java | 15 + .../ticketing/art/dto/ArtResponse.java | 20 +- .../ticketing/art/dto/ArtSearchCondition.java | 13 + .../client/domain/ClientRepository.java | 3 + src/main/resources/static/css/art/create.css | 480 +++++++++--------- src/main/resources/static/css/art/gallery.css | 191 ++++++- src/main/resources/static/js/art/create.js | 24 +- src/main/resources/static/js/art/gallery.js | 90 +++- src/main/resources/templates/art/create.html | 69 +-- src/main/resources/templates/art/gallery.html | 28 +- 19 files changed, 829 insertions(+), 415 deletions(-) create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtQueryCondition.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtRepositoryCustom.java create mode 100644 src/main/java/com/example/ticketing/art/domain/ArtRepositoryImpl.java create mode 100644 src/main/java/com/example/ticketing/art/dto/ArtLikeResponse.java create mode 100644 src/main/java/com/example/ticketing/art/dto/ArtSearchCondition.java diff --git a/.gitignore b/.gitignore index 0ebf7ed..8990286 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ src/main/generated/** ads.txt navercc99a5b089096abdec8e622dcd5b05e9.html logs +CLAUDE.md diff --git a/src/main/java/com/example/ticketing/art/application/ArtController.java b/src/main/java/com/example/ticketing/art/application/ArtController.java index 3d6a2a2..bd86a68 100644 --- a/src/main/java/com/example/ticketing/art/application/ArtController.java +++ b/src/main/java/com/example/ticketing/art/application/ArtController.java @@ -1,8 +1,6 @@ package com.example.ticketing.art.application; -import com.example.ticketing.art.dto.ArtCreateRequest; -import com.example.ticketing.art.dto.ArtResponse; -import com.example.ticketing.art.dto.ArtUpdateRequest; +import com.example.ticketing.art.dto.*; import com.example.ticketing.common.auth.Auth; import com.example.ticketing.common.auth.ClientInfo; import jakarta.validation.Valid; @@ -10,7 +8,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,9 +21,22 @@ public class ArtController { private final ArtService artService; @GetMapping - public ResponseEntity> getPublicArts( - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - Page response = artService.getPublicArts(pageable); + public ResponseEntity> searchArts( + @Auth ClientInfo clientInfo, + @RequestParam(required = false) String keyword, + @RequestParam(required = false, defaultValue = "latest") String sortBy, + @RequestParam(required = false, defaultValue = "desc") String sortDirection, + @RequestParam(required = false) Boolean onlyMine, + @PageableDefault(size = 20) Pageable pageable) { + + ArtSearchCondition condition = ArtSearchCondition.builder() + .keyword(keyword) + .sortBy(sortBy) + .sortDirection(sortDirection) + .onlyMine(onlyMine) + .build(); + + Page response = artService.searchArts(condition, clientInfo, pageable); return ResponseEntity.ok(response); } @@ -36,14 +46,6 @@ public ResponseEntity getArt(@PathVariable Long artId) { return ResponseEntity.ok(response); } - @GetMapping("/my") - public ResponseEntity> getMyArts( - @Auth ClientInfo clientInfo, - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - Page response = artService.getMyArts(clientInfo, pageable); - return ResponseEntity.ok(response); - } - @PostMapping public ResponseEntity createArt( @Valid @RequestBody ArtCreateRequest request, @@ -69,4 +71,10 @@ public ResponseEntity deleteArt( artService.deleteArt(artId, clientInfo); return ResponseEntity.noContent().build(); } + + @PostMapping("/{artId}/like") + public ResponseEntity toggleLike(@PathVariable Long artId, @Auth ClientInfo clientInfo) { + ArtLikeResponse artLikeResponse = artService.toggleLike(artId, clientInfo); + return ResponseEntity.ok(artLikeResponse); + } } \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/application/ArtService.java b/src/main/java/com/example/ticketing/art/application/ArtService.java index 9500c14..e1690b3 100644 --- a/src/main/java/com/example/ticketing/art/application/ArtService.java +++ b/src/main/java/com/example/ticketing/art/application/ArtService.java @@ -1,30 +1,31 @@ package com.example.ticketing.art.application; -import com.example.ticketing.art.domain.Art; -import com.example.ticketing.art.domain.ArtRepository; -import com.example.ticketing.art.dto.ArtCreateRequest; -import com.example.ticketing.art.dto.ArtResponse; -import com.example.ticketing.art.dto.ArtUpdateRequest; +import com.example.ticketing.art.domain.*; +import com.example.ticketing.art.dto.*; import com.example.ticketing.client.component.ClientManager; import com.example.ticketing.client.domain.Client; +import com.example.ticketing.client.domain.ClientRepository; import com.example.ticketing.common.auth.ClientInfo; import com.example.ticketing.common.exception.ErrorCode; import com.example.ticketing.common.exception.GlobalException; -import com.fasterxml.jackson.databind.ObjectMapper; 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.stream.Collectors; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ArtService { private final ArtRepository artRepository; + private final ArtLikeRepository artLikeRepository; private final ClientManager clientManager; - private final ObjectMapper objectMapper; + private final ClientRepository clientRepository; @Transactional public ArtResponse createArt(ArtCreateRequest request, ClientInfo clientInfo) { @@ -33,11 +34,9 @@ public ArtResponse createArt(ArtCreateRequest request, ClientInfo clientInfo) { Art art = Art.builder() .title(request.getTitle()) - .description(request.getDescription()) .pixelData(request.getPixelData()) .width(request.getWidth()) .height(request.getHeight()) - .isPublic(request.getIsPublic()) .client(client) .build(); @@ -45,13 +44,46 @@ public ArtResponse createArt(ArtCreateRequest request, ClientInfo clientInfo) { return ArtResponse.from(savedArt); } - public Page getPublicArts(Pageable pageable) { - Page arts = artRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable); - return arts.map(ArtResponse::from); + public Page searchArts(ArtSearchCondition condition, ClientInfo clientInfo, Pageable pageable) { + // 1. 키워드로 닉네임 검색하여 clientId 목록 추출 + List matchedClientIds = null; + if (condition.getKeyword() != null && !condition.getKeyword().isEmpty()) { + List matchedClients = clientRepository.findByNameContainingIgnoreCase(condition.getKeyword()); + matchedClientIds = matchedClients.stream() + .map(Client::getId) + .collect(Collectors.toList()); + } + + // 2. ArtQueryCondition 생성 + Long currentClientId = null; + if (condition.getOnlyMine() != null && condition.getOnlyMine() && clientInfo != null) { + currentClientId = clientInfo.getClientId(); + } + + ArtQueryCondition queryCondition = ArtQueryCondition.builder() + .keyword(condition.getKeyword()) + .matchedClientIds(matchedClientIds) + .sortBy(condition.getSortBy()) + .sortDirection(condition.getSortDirection() != null ? condition.getSortDirection() : "desc") + .currentClientId(currentClientId) + .build(); + + // 3. 검색 실행 + Page arts = artRepository.searchArts(queryCondition, pageable); + + // 4. 좋아요 정보 포함하여 응답 생성 + Client client = clientInfo != null ? clientManager.findById(clientInfo.getClientId()) : null; + if (client == null) { + return arts.map(art -> ArtResponse.from(art, false)); + } + return arts.map(art -> { + boolean isLiked = artLikeRepository.existsByArtAndClient(art, client); + return ArtResponse.from(art, isLiked); + }); } public ArtResponse getArt(Long artId) { - Art art = artRepository.findByIdAndIsPublicTrue(artId) + Art art = artRepository.findById(artId) .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); // 조회수 증가 @@ -61,12 +93,6 @@ public ArtResponse getArt(Long artId) { return ArtResponse.from(art); } - public Page getMyArts(ClientInfo clientInfo, Pageable pageable) { - Client client = clientManager.findById(clientInfo.getClientId()); - Page arts = artRepository.findByClientAndIsPublicTrueOrderByCreatedAtDesc(client, pageable); - return arts.map(ArtResponse::from); - } - @Transactional public ArtResponse updateArt(Long artId, ArtUpdateRequest request, ClientInfo clientInfo) { Art art = artRepository.findById(artId) @@ -76,10 +102,7 @@ public ArtResponse updateArt(Long artId, ArtUpdateRequest request, ClientInfo cl throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); } - art.updateInfo(request.getTitle(), request.getDescription()); - if (request.getIsPublic() != null) { - art.toggleVisibility(); - } + art.updateInfo(request.getTitle()); return ArtResponse.from(art); } @@ -96,6 +119,37 @@ public void deleteArt(Long artId, ClientInfo clientInfo) { artRepository.delete(art); } + @Transactional + public ArtLikeResponse toggleLike(Long artId, ClientInfo clientInfo) { + Art art = artRepository.findById(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + Client client = clientManager.findById(clientInfo.getClientId()); + int likeCount = art.getLikeCount(); + + boolean isLiked; + if (artLikeRepository.existsByArtAndClient(art, client)) { + artLikeRepository.deleteByArtAndClient(art, client); + artRepository.decrementLikeCount(artId); + isLiked = false; + likeCount--; + } else { + ArtLike artLike = ArtLike.builder() + .art(art) + .client(client) + .build(); + artLikeRepository.save(artLike); + artRepository.incrementLikeCount(artId); + isLiked = true; + likeCount++; + } + + return ArtLikeResponse.builder() + .isLiked(isLiked) + .likeCount(likeCount) + .build(); + } + private void validatePixelData(String pixelData, Integer width, Integer height) { if (pixelData == null || pixelData.isEmpty()) { throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR); diff --git a/src/main/java/com/example/ticketing/art/domain/Art.java b/src/main/java/com/example/ticketing/art/domain/Art.java index 6001b26..95d51be 100644 --- a/src/main/java/com/example/ticketing/art/domain/Art.java +++ b/src/main/java/com/example/ticketing/art/domain/Art.java @@ -26,9 +26,6 @@ public class Art { @Column(nullable = false) private String title; - @Column(nullable = false, length = 1000) - private String description; - @Column(nullable = false) private String pixelData; @@ -46,10 +43,6 @@ public class Art { @Builder.Default private Integer viewCount = 0; - @Column(nullable = false) - @Builder.Default - private Boolean isPublic = true; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "client_id", nullable = false) private Client client; @@ -70,26 +63,11 @@ public class Art { @Column(nullable = false) private LocalDateTime updatedAt; - public void incrementLikeCount() { - this.likeCount++; - } - - public void decrementLikeCount() { - if (this.likeCount > 0) { - this.likeCount--; - } - } - public void incrementViewCount() { this.viewCount++; } - public void updateInfo(String title, String description) { + public void updateInfo(String title) { this.title = title; - this.description = description; - } - - public void toggleVisibility() { - this.isPublic = !this.isPublic; } } \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtQueryCondition.java b/src/main/java/com/example/ticketing/art/domain/ArtQueryCondition.java new file mode 100644 index 0000000..6a36516 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtQueryCondition.java @@ -0,0 +1,16 @@ +package com.example.ticketing.art.domain; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ArtQueryCondition { + private String keyword; // 제목 검색용 + private List matchedClientIds; // 닉네임 검색으로 매칭된 clientId들 + private String sortBy; // latest, like, view, comment + private String sortDirection; // asc, desc + private Long currentClientId; // 내 작품만 보기 필터용 +} diff --git a/src/main/java/com/example/ticketing/art/domain/ArtRepository.java b/src/main/java/com/example/ticketing/art/domain/ArtRepository.java index 5e4b59c..66913b7 100644 --- a/src/main/java/com/example/ticketing/art/domain/ArtRepository.java +++ b/src/main/java/com/example/ticketing/art/domain/ArtRepository.java @@ -1,34 +1,21 @@ package com.example.ticketing.art.domain; import com.example.ticketing.client.domain.Client; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; -import java.util.Optional; - -public interface ArtRepository extends JpaRepository { - - Page findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable); - - Page findByIsPublicTrueOrderByLikeCountDescCreatedAtDesc(Pageable pageable); - - Page findByIsPublicTrueOrderByViewCountDescCreatedAtDesc(Pageable pageable); - - Page findByClientAndIsPublicTrueOrderByCreatedAtDesc(Client client, Pageable pageable); - - @Query("SELECT a FROM Art a WHERE a.isPublic = true AND " + - "(LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + - "LOWER(a.description) LIKE LOWER(CONCAT('%', :keyword, '%')))") - Page findByKeywordAndIsPublicTrue(@Param("keyword") String keyword, Pageable pageable); - - Optional findByIdAndIsPublicTrue(Long id); - - List findTop10ByIsPublicTrueOrderByLikeCountDescCreatedAtDesc(); +public interface ArtRepository extends JpaRepository, ArtRepositoryCustom { @Query("SELECT COUNT(a) FROM Art a WHERE a.client = :client") Long countByClient(@Param("client") Client client); + + @Modifying + @Query("UPDATE Art a SET a.likeCount = a.likeCount + 1 WHERE a.id = :id") + void incrementLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Art a SET a.likeCount = a.likeCount - 1 WHERE a.id = :id AND a.likeCount > 0") + void decrementLikeCount(@Param("id") Long id); } \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/ArtRepositoryCustom.java b/src/main/java/com/example/ticketing/art/domain/ArtRepositoryCustom.java new file mode 100644 index 0000000..d9c1160 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.example.ticketing.art.domain; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ArtRepositoryCustom { + Page searchArts(ArtQueryCondition condition, Pageable pageable); +} diff --git a/src/main/java/com/example/ticketing/art/domain/ArtRepositoryImpl.java b/src/main/java/com/example/ticketing/art/domain/ArtRepositoryImpl.java new file mode 100644 index 0000000..ad5eb08 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/ArtRepositoryImpl.java @@ -0,0 +1,83 @@ +package com.example.ticketing.art.domain; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; + +import static com.example.ticketing.art.domain.QArt.art; +import static com.example.ticketing.client.domain.QClient.client; + +@Repository +@RequiredArgsConstructor +public class ArtRepositoryImpl implements ArtRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchArts(ArtQueryCondition condition, Pageable pageable) { + List content = queryFactory + .selectFrom(art) + .leftJoin(art.client, client).fetchJoin() + .where( + keywordCondition(condition.getKeyword()), + clientIdsCondition(condition.getMatchedClientIds()), + onlyMineCondition(condition.getCurrentClientId()) + ) + .orderBy(getOrderSpecifiers(condition.getSortBy(), condition.getSortDirection())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(art.count()) + .from(art) + .where( + keywordCondition(condition.getKeyword()), + clientIdsCondition(condition.getMatchedClientIds()), + onlyMineCondition(condition.getCurrentClientId()) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + private BooleanExpression keywordCondition(String keyword) { + return keyword != null ? art.title.containsIgnoreCase(keyword) : null; + } + + private BooleanExpression clientIdsCondition(List clientIds) { + return clientIds != null && !clientIds.isEmpty() ? art.client.id.in(clientIds) : null; + } + + private BooleanExpression onlyMineCondition(Long currentClientId) { + return currentClientId != null ? art.client.id.eq(currentClientId) : null; + } + + private OrderSpecifier[] getOrderSpecifiers(String sortBy, String sortDirection) { + List> orderSpecifiers = new ArrayList<>(); + boolean isAsc = "asc".equalsIgnoreCase(sortDirection); + + if (sortBy == null || "latest".equals(sortBy)) { + orderSpecifiers.add(isAsc ? art.createdAt.asc() : art.createdAt.desc()); + } else if ("like".equals(sortBy)) { + orderSpecifiers.add(isAsc ? art.likeCount.asc() : art.likeCount.desc()); + orderSpecifiers.add(art.createdAt.desc()); + } else if ("view".equals(sortBy)) { + orderSpecifiers.add(isAsc ? art.viewCount.asc() : art.viewCount.desc()); + orderSpecifiers.add(art.createdAt.desc()); + } else if ("comment".equals(sortBy)) { + orderSpecifiers.add(isAsc ? art.comments.size().asc() : art.comments.size().desc()); + orderSpecifiers.add(art.createdAt.desc()); + } + + return orderSpecifiers.toArray(new OrderSpecifier[0]); + } +} diff --git a/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java b/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java index 440554e..1ace82b 100644 --- a/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java +++ b/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java @@ -10,13 +10,9 @@ public class ArtCreateRequest { @NotBlank(message = "제목을 입력해주세요.") - @Size(max = 100, message = "제목은 100자 이하로 입력해주세요.") + @Size(max = 20, message = "제목은 20자 이하로 입력해주세요.") private String title; - @NotBlank(message = "설명을 입력해주세요.") - @Size(max = 1000, message = "설명은 1000자 이하로 입력해주세요.") - private String description; - @NotBlank(message = "픽셀 데이터를 입력해주세요.") private String pixelData; @@ -27,6 +23,4 @@ public class ArtCreateRequest { @NotNull(message = "세로 크기를 입력해주세요.") @Positive(message = "세로 크기는 양수여야 합니다.") private Integer height; - - private Boolean isPublic = true; } \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/dto/ArtLikeResponse.java b/src/main/java/com/example/ticketing/art/dto/ArtLikeResponse.java new file mode 100644 index 0000000..314ed0c --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtLikeResponse.java @@ -0,0 +1,15 @@ +package com.example.ticketing.art.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArtLikeResponse { + private Boolean isLiked; + private int likeCount; +} diff --git a/src/main/java/com/example/ticketing/art/dto/ArtResponse.java b/src/main/java/com/example/ticketing/art/dto/ArtResponse.java index bf8584c..cbfed95 100644 --- a/src/main/java/com/example/ticketing/art/dto/ArtResponse.java +++ b/src/main/java/com/example/ticketing/art/dto/ArtResponse.java @@ -12,13 +12,11 @@ public class ArtResponse { private Long id; private String title; - private String description; private String pixelData; private Integer width; private Integer height; private Integer likeCount; private Integer viewCount; - private Boolean isPublic; private String authorName; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -28,17 +26,31 @@ public static ArtResponse from(Art art) { return ArtResponse.builder() .id(art.getId()) .title(art.getTitle()) - .description(art.getDescription()) .pixelData(art.getPixelData()) .width(art.getWidth()) .height(art.getHeight()) .likeCount(art.getLikeCount()) .viewCount(art.getViewCount()) - .isPublic(art.getIsPublic()) .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") .createdAt(art.getCreatedAt()) .updatedAt(art.getUpdatedAt()) .isLikedByCurrentUser(false) .build(); } + + public static ArtResponse from(Art art, boolean isLiked) { + return ArtResponse.builder() + .id(art.getId()) + .title(art.getTitle()) + .pixelData(art.getPixelData()) + .width(art.getWidth()) + .height(art.getHeight()) + .likeCount(art.getLikeCount()) + .viewCount(art.getViewCount()) + .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") + .createdAt(art.getCreatedAt()) + .updatedAt(art.getUpdatedAt()) + .isLikedByCurrentUser(isLiked) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/dto/ArtSearchCondition.java b/src/main/java/com/example/ticketing/art/dto/ArtSearchCondition.java new file mode 100644 index 0000000..4ae510d --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtSearchCondition.java @@ -0,0 +1,13 @@ +package com.example.ticketing.art.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ArtSearchCondition { + private String keyword; // 제목 또는 작성자명 검색 + private String sortBy; // latest, like, view, comment + private String sortDirection; // asc, desc (기본값 desc) + private Boolean onlyMine; // 내 작품만 보기 +} diff --git a/src/main/java/com/example/ticketing/client/domain/ClientRepository.java b/src/main/java/com/example/ticketing/client/domain/ClientRepository.java index 5eddbf7..1e33a5d 100644 --- a/src/main/java/com/example/ticketing/client/domain/ClientRepository.java +++ b/src/main/java/com/example/ticketing/client/domain/ClientRepository.java @@ -5,12 +5,15 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ClientRepository extends JpaRepository { Optional findByToken(String token); + List findByNameContainingIgnoreCase(String name); + @Modifying(clearAutomatically = true) @Query("update Client c set c.name = :name where c.id = :clientId") void updateNameById(@Param("clientId") Long clientId, @Param("name") String name); diff --git a/src/main/resources/static/css/art/create.css b/src/main/resources/static/css/art/create.css index 2afd2e4..db8651b 100644 --- a/src/main/resources/static/css/art/create.css +++ b/src/main/resources/static/css/art/create.css @@ -1,284 +1,280 @@ -.create-container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; -} - -.create-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 30px; - border-bottom: 2px solid #8b4992; - padding-bottom: 15px; -} - -.create-header h1 { - color: #8b4992; - margin: 0; - font-size: 2rem; -} - -.back-btn { - background: #6c757d; - color: white; - padding: 12px 24px; - text-decoration: none; - border-radius: 8px; - font-weight: bold; - transition: background-color 0.3s; -} - -.back-btn:hover { - background: #5a6268; -} - -.create-content { - display: grid; - grid-template-columns: 1fr 400px; - gap: 40px; -} - -.canvas-section { - display: flex; - flex-direction: column; - gap: 20px; -} - -.canvas-controls { - display: flex; - gap: 30px; - align-items: center; - padding: 16px; - background: #f8f9fa; - border-radius: 8px; - flex-wrap: wrap; -} - -.size-controls, .color-controls { - display: flex; - align-items: center; - gap: 10px; -} - -.size-controls label, .color-controls label { - font-weight: bold; - color: #333; -} - -#canvasSize { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - background: white; -} - -#colorPicker { - width: 50px; - height: 40px; - border: none; - border-radius: 4px; - cursor: pointer; -} - -.tool-btn { - padding: 8px 16px; - border: 1px solid #8b4992; - background: white; - color: #8b4992; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - transition: all 0.3s; -} +/* 데스크톱 스타일 */ +@media (min-width: 769px) { + html, body { + height: 100vh; + width: 100vw; + margin: 0; + display: grid; + justify-items: center; + background: white; + font-family: my-font, sans-serif; + grid-template-rows: 1fr 10fr; + } -.tool-btn:hover, .tool-btn.active { - background: #8b4992; - color: white; -} + #content-section { + display: grid; + justify-items: center; + align-items: center; + width: 100vw; + height: 90%; + max-height: 100%; + overflow: scroll; + grid-template-columns: 1fr 2fr 1fr; + } -.canvas-wrapper { - display: flex; - justify-content: center; - padding: 20px; - background: white; - border-radius: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} + #art-create-section { + display: grid; + grid-template-rows: auto 1fr; + width: 100%; + height: 100%; + max-height: 100%; + font-family: my-font, sans-serif; + overflow: hidden; + box-sizing: border-box; + } -#pixelCanvas { - border-radius: 8px; - cursor: crosshair; - image-rendering: pixelated; - image-rendering: -moz-crisp-edges; - image-rendering: crisp-edges; -} + #create-header-section { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + } -.form-section { - background: white; - padding: 24px; - border-radius: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - height: fit-content; -} + #header-title { + color: darkslateblue; + font-size: 25px; + margin: 0; + } -.form-group { - margin-bottom: 20px; -} + #create-content-section { + width: 100%; + height: 100%; + padding: 2% 5%; + box-sizing: border-box; + overflow-y: auto; + display: flex; + align-items: flex-start; + justify-content: center; + } -.form-group label { - display: block; - margin-bottom: 8px; - font-weight: bold; - color: #333; -} + #art-form { + width: 100%; + max-width: 560px; + background: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; + } -.form-group input[type="text"], -.form-group textarea { - width: 100%; - padding: 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - box-sizing: border-box; -} + #canvas-section { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + flex-shrink: 0; + } -.form-group textarea { - resize: vertical; - min-height: 100px; -} + #canvas-wrapper { + display: flex; + justify-content: center; + align-items: center; + background: white; + border-radius: 12px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-width: 100%; + overflow: auto; + } -.form-group input[type="checkbox"] { - margin-right: 8px; -} + #pixel-canvas { + border-radius: 8px; + cursor: cell; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } -.form-actions { - display: flex; - gap: 12px; - margin-top: 24px; -} + #title-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + } -.submit-btn { - flex: 1; - padding: 14px 24px; - background: #8b4992; - color: white; - border: none; - border-radius: 8px; - font-size: 16px; - font-weight: bold; - cursor: pointer; - transition: background-color 0.3s; -} + #title-section label { + font-weight: bold; + color: #333; + font-family: my-font, sans-serif; + } -.submit-btn:hover { - background: #6d3674; -} + #title { + padding: 12px 16px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 15px; + font-family: my-font, sans-serif; + box-sizing: border-box; + outline: none; + } -.preview-btn { - padding: 14px 24px; - background: #6c757d; - color: white; - border: none; - border-radius: 8px; - font-size: 16px; - font-weight: bold; - cursor: pointer; - transition: background-color 0.3s; -} + #title:focus { + border-color: #514897; + } -.preview-btn:hover { - background: #5a6268; -} + #submit-btn { + width: 100%; + padding: 16px; + background: #514897; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + transition: background-color 0.3s; + } -.modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.7); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; + #submit-btn:hover { + background: rgba(72, 61, 139, 0.68); + } } -.modal-content { - background: white; - padding: 24px; - border-radius: 12px; - text-align: center; - max-width: 90%; - max-height: 90%; - overflow: auto; -} +/* 모바일 스타일 */ +@media (max-width: 768px) { + #art-create-section { + width: 100%; + height: 100%; + display: grid; + grid-template-rows: auto 1fr; + font-family: my-font, sans-serif; + overflow: hidden; + box-sizing: border-box; + } -.modal-content h3 { - margin: 0 0 20px 0; - color: #8b4992; -} + #create-header-section { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + flex-shrink: 0; + } -#previewCanvas { - border: 2px solid #8b4992; - border-radius: 8px; - margin: 16px 0; - image-rendering: pixelated; - image-rendering: -moz-crisp-edges; - image-rendering: crisp-edges; -} + #header-title { + color: darkslateblue; + font-size: 1.3rem; + margin: 0; + } -.close-btn { - padding: 12px 24px; - background: #6c757d; - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - margin-top: 16px; -} + #back-to-gallery-btn { + padding: 8px 14px; + background: #514897; + color: white; + text-decoration: none; + border-radius: 8px; + font-family: my-font, sans-serif; + font-size: 0.85rem; + } -.close-btn:hover { - background: #5a6268; -} + #create-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 16px 16px 16px; + box-sizing: border-box; + } -@media (max-width: 1024px) { - .create-content { - grid-template-columns: 1fr; - gap: 30px; + #create-content-section { + width: 100%; + height: 100%; + padding: 0 16px 16px 16px; + box-sizing: border-box; + overflow-y: auto; + display: flex; + align-items: flex-start; + justify-content: center; } - .canvas-controls { + #art-form { + width: 100%; + background: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: flex; flex-direction: column; - align-items: stretch; gap: 16px; + box-sizing: border-box; } - .size-controls, .color-controls { - justify-content: center; + #canvas-section { + display: flex; + flex-direction: column; + gap: 12px; + flex-shrink: 0; } -} -@media (max-width: 768px) { - .create-container { + #canvas-wrapper { + background: white; + border-radius: 12px; padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: flex; + justify-content: center; + align-items: center; + max-width: 100%; + overflow: auto; + } + + #pixel-canvas { + border-radius: 8px; + cursor: pointer; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; } - .create-header { + #title-section { + display: flex; flex-direction: column; - gap: 16px; - text-align: center; + gap: 8px; } - .create-header h1 { - font-size: 1.5rem; + #title-section label { + font-weight: bold; + color: #333; + font-family: my-font, sans-serif; } - .canvas-controls { + #title { + width: 100%; padding: 12px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 14px; + font-family: my-font, sans-serif; + box-sizing: border-box; + outline: none; } - .form-actions { - flex-direction: column; + #title:focus { + border-color: #514897; } -} \ No newline at end of file + + #submit-btn { + width: 100%; + padding: 14px; + background: #514897; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + } +} diff --git a/src/main/resources/static/css/art/gallery.css b/src/main/resources/static/css/art/gallery.css index 6aa6f2c..fde1cc5 100644 --- a/src/main/resources/static/css/art/gallery.css +++ b/src/main/resources/static/css/art/gallery.css @@ -46,7 +46,6 @@ width: 100%; height: 100%; max-height: 100%; - flex-direction: column; align-items: center; font-family: my-font, sans-serif; overflow: hidden; @@ -55,19 +54,26 @@ #gallery-header-section { display: grid; - grid-auto-rows: 3fr 2fr; + grid-template-rows: 1fr 4fr; text-align: center; align-items: center; + justify-items: center; + height: 100%; width: 100%; max-height: 100%; overflow: hidden; box-sizing: border-box; + row-gap: 10%; } #gallery-title { color: darkslateblue; font-size: 25px; text-align: center; + margin: 0; + display: flex; + align-items: center; + justify-content: center; } #gallery-content { @@ -80,33 +86,143 @@ align-items: center; } - #create-btn-wrapper { + #gallery-controls { + width: 80%; + padding: 2%; + box-sizing: border-box; + overflow: hidden; + height: 100%; + display: grid; + grid-template-rows: 1fr 1fr; + row-gap: 10%; + align-items: center; + justify-items: center; + + } + + #search-wrapper { + display: flex; + width: 100%; + align-items: center; + justify-items: center; + gap: 5%; + } + + #search-input { + flex: 1; + min-width: 200px; + padding: 10px 15px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-family: my-font, sans-serif; + outline: none; + color: #8e8e8e; + } + + #search-input:focus { + border-color: #514897; + } + + #sort-create-wrapper { display: flex; width: 100%; - justify-content: flex-end; + height: 100%; + max-height: 100%; + justify-content: space-between; + } + + #sort-wrapper { + display: grid; + width: 50%; + grid-template-columns: 3fr 1fr; + } + + #sort-select { + padding: 10px 15px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-family: my-font, sans-serif; + background: white; + cursor: pointer; + outline: none; + color: #8e8e8e; + text-align: center; + } + + #sort-select:hover { + border-color: #514897; + } + + #direction-toggle { + padding: 10px; + border: 1px solid #dbdbdb; + border-radius: 8px; + background: white; + cursor: pointer; + outline: none; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + #direction-toggle:hover { + border-color: #514897; + background: #f5f5f5; + } + + #direction-toggle[data-direction="desc"] { + color: #262626; + } + + #direction-toggle[data-direction="asc"] { + color: #262626; + } + + #create-btn-wrapper { + display: flex; + justify-content: center; box-sizing: border-box; - padding: 0 10%; height: 100%; max-height: 100%; overflow: hidden; + width: 20%; } #create-art-btn { - padding: 15px; + padding: 5%; background: #514897; color: white; - font-size: 1rem; - text-decoration: none; border-radius: 8px; font-family: my-font, sans-serif; - font-weight: bold; - display: block; + display: flex; + text-align: center; + text-decoration: none; + width: 100%; + align-items: center; + justify-content: center; } #create-art-btn:hover { background: rgba(72, 61, 139, 0.68); } + #only-mine-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.95rem; + font-family: my-font, sans-serif; + cursor: pointer; + user-select: none; + } + + #only-mine-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + } + .art-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); @@ -247,6 +363,61 @@ /* 모바일 스타일 */ @media (max-width: 768px) { + #gallery-controls { + width: 100%; + padding: 15px; + box-sizing: border-box; + flex-shrink: 0; + } + + #search-filter-wrapper { + display: flex; + flex-direction: column; + gap: 10px; + } + + #search-input { + width: 100%; + padding: 10px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 0.9rem; + font-family: my-font, sans-serif; + outline: none; + } + + #search-input:focus { + border-color: #514897; + } + + #sort-select, #direction-select { + width: 100%; + padding: 10px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 0.9rem; + font-family: my-font, sans-serif; + background: white; + cursor: pointer; + outline: none; + } + + #only-mine-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + font-family: my-font, sans-serif; + cursor: pointer; + user-select: none; + } + + #only-mine-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + } + #art-gallery-section { width: 100%; height: 100%; diff --git a/src/main/resources/static/js/art/create.js b/src/main/resources/static/js/art/create.js index fc9ea92..40c3db0 100644 --- a/src/main/resources/static/js/art/create.js +++ b/src/main/resources/static/js/art/create.js @@ -2,9 +2,9 @@ import * as util from "../common.js"; const CONFIG = { gridSize: 30, - cellSize: 24, - gap: 6, - corner: 6, + cellSize: 16, + gap: 4, + corner: 4, colors: { base: "#6633cc", hover: "#7c4dff", @@ -153,25 +153,21 @@ class GrapePalette { event.preventDefault(); const title = this.form.querySelector("#title")?.value.trim(); - const description = this.form.querySelector("#description")?.value.trim(); - const isPublic = this.form.querySelector("#isPublic")?.checked ?? false; - if (!title || !description) { - alert("제목과 설명을 모두 입력해주세요."); + if (!title) { + alert("제목을 입력해주세요."); return; } const artData = { title, - description, pixel_data: this.toPixelData(), width: this.gridSize, - height: this.gridSize, - is_public: isPublic + height: this.gridSize }; try { - const response = await util.authFetch(`${window.location.origin}/api/arts`, { + const response = await util.authFetch(`${HOST}/api/arts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(artData), @@ -190,8 +186,8 @@ class GrapePalette { document.addEventListener("DOMContentLoaded", () => { new GrapePalette({ - canvasId: "pixelCanvas", - resetId: "clearBtn", - formId: "artForm" + canvasId: "pixel-canvas", + resetId: "clear-btn", + formId: "art-form" }); }); diff --git a/src/main/resources/static/js/art/gallery.js b/src/main/resources/static/js/art/gallery.js index 14ee604..6bdbd6c 100644 --- a/src/main/resources/static/js/art/gallery.js +++ b/src/main/resources/static/js/art/gallery.js @@ -24,14 +24,82 @@ class Gallery { this.size = 20; this.hasMore = true; this.galleryContainer = document.getElementById("gallery-grid"); + + // 검색/필터 상태 + this.searchKeyword = ""; + this.sortBy = "latest"; + this.sortDirection = "desc"; + this.onlyMine = false; + this.init(); } init() { + this.setupControls(); this.loadArts(); this.setupInfiniteScroll(); } + setupControls() { + const searchInput = document.getElementById("search-input"); + const sortSelect = document.getElementById("sort-select"); + const directionToggle = document.getElementById("direction-toggle"); + const onlyMineCheckbox = document.getElementById("only-mine-checkbox"); + + // 검색 입력 (디바운스 적용) + let searchTimeout; + searchInput.addEventListener("input", (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.searchKeyword = e.target.value.trim(); + this.resetAndReload(); + }, 500); + }); + + // 정렬 기준 변경 + sortSelect.addEventListener("change", (e) => { + this.sortBy = e.target.value; + this.resetAndReload(); + }); + + // 정렬 방향 토글 + directionToggle.addEventListener("click", () => { + if (this.sortDirection === "desc") { + this.sortDirection = "asc"; + directionToggle.setAttribute("data-direction", "asc"); + directionToggle.setAttribute("title", "오름차순"); + directionToggle.innerHTML = ` + + + + `; + } else { + this.sortDirection = "desc"; + directionToggle.setAttribute("data-direction", "desc"); + directionToggle.setAttribute("title", "내림차순"); + directionToggle.innerHTML = ` + + + + `; + } + this.resetAndReload(); + }); + + // 내 작품만 보기 + onlyMineCheckbox.addEventListener("change", (e) => { + this.onlyMine = e.target.checked; + this.resetAndReload(); + }); + } + + resetAndReload() { + this.currentPage = 0; + this.hasMore = true; + this.galleryContainer.innerHTML = ""; + this.loadArts(); + } + setupInfiniteScroll() { window.addEventListener("scroll", () => { if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) { @@ -40,12 +108,32 @@ class Gallery { }); } + buildQueryString() { + const params = new URLSearchParams(); + params.append("page", this.currentPage); + params.append("size", this.size); + + if (this.searchKeyword) { + params.append("keyword", this.searchKeyword); + } + + params.append("sortBy", this.sortBy); + params.append("sortDirection", this.sortDirection); + + if (this.onlyMine) { + params.append("onlyMine", "true"); + } + + return params.toString(); + } + async loadArts() { if (!this.hasMore) { return; } try { - const response = await util.authFetch(`${HOST}/api/arts?page=${this.currentPage}&size=${this.size}`); + const queryString = this.buildQueryString(); + const response = await util.authFetch(`${HOST}/api/arts?${queryString}`); const data = await response.json(); if (data.content && data.content.length > 0) { diff --git a/src/main/resources/templates/art/create.html b/src/main/resources/templates/art/create.html index 4220b1f..d4bd081 100644 --- a/src/main/resources/templates/art/create.html +++ b/src/main/resources/templates/art/create.html @@ -5,71 +5,38 @@ 작품 만들기 - 프랙티켓 +
- -
-
-

픽셀 아트 만들기

- 갤러리로 돌아가기 -
- -
-
-
-
- 포도알로 여러분의 팬아트를 보여주세요. -
-
- -
-
- -
- -
+
+
+
+
+

포도아트 만들기

- -
-
-
- - -
- -
- - -
- -
- +
+ +
+ +
- -
- - +
+
+ +
+
-
- - + diff --git a/src/main/resources/templates/art/gallery.html b/src/main/resources/templates/art/gallery.html index d1991b6..0bd87ec 100644 --- a/src/main/resources/templates/art/gallery.html +++ b/src/main/resources/templates/art/gallery.html @@ -18,8 +18,32 @@