diff --git a/.gitignore b/.gitignore index 0ebf7ed..dd50640 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,6 @@ out/ src/main/resources/application-*.yml 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 new file mode 100644 index 0000000..9921031 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/application/ArtController.java @@ -0,0 +1,103 @@ +package com.example.ticketing.art.application; + +import com.example.ticketing.art.dto.*; +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.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> searchArts( + @Auth ClientInfo clientInfo, + @ModelAttribute ArtSearchCondition condition, + @PageableDefault(size = 20) Pageable pageable) { + Page response = artService.searchArts(condition, clientInfo, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/{artId}") + public ResponseEntity getArt(@Auth ClientInfo clientInfo, @PathVariable Long artId) { + ArtResponse response = artService.getArt(clientInfo, artId); + return ResponseEntity.ok(response); + } + + @PostMapping + public ResponseEntity createArt( + @Valid @RequestBody ArtCreateRequest request, + @Auth ClientInfo clientInfo) { + 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(); + } + + @PostMapping("/{artId}/like") + public ResponseEntity toggleLike(@PathVariable Long artId, @Auth ClientInfo clientInfo) { + ArtLikeResponse artLikeResponse = artService.toggleLike(artId, clientInfo); + return ResponseEntity.ok(artLikeResponse); + } + + @GetMapping("/{artId}/comments") + public ResponseEntity> getComments( + @PathVariable Long artId, + @Auth ClientInfo clientInfo, + @PageableDefault(size = 20) Pageable pageable) { + Page comments = artService.getComments(artId, clientInfo, pageable); + return ResponseEntity.ok(comments); + } + + @PostMapping("/{artId}/comments") + public ResponseEntity createComment( + @PathVariable Long artId, + @Valid @RequestBody ArtCommentRequest request, + @Auth ClientInfo clientInfo) { + ArtCommentResponse response = artService.createComment(artId, request, clientInfo); + return ResponseEntity.ok(response); + } + + @PutMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody ArtCommentRequest request, + @Auth ClientInfo clientInfo) { + ArtCommentResponse response = artService.updateComment(commentId, request, clientInfo); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @Auth ClientInfo clientInfo) { + artService.deleteComment(commentId, 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..1ecbd04 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/application/ArtService.java @@ -0,0 +1,234 @@ +package com.example.ticketing.art.application; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.art.domain.entity.ArtComment; +import com.example.ticketing.art.domain.entity.ArtLike; +import com.example.ticketing.art.domain.entity.ArtView; +import com.example.ticketing.art.domain.repository.*; +import com.example.ticketing.art.dto.*; +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.example.ticketing.common.exception.ValidateException; +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 ArtLikeRepository artLikeRepository; + private final ArtCommentRepository artCommentRepository; + private final ArtViewRepository artViewRepository; + private final ClientManager clientManager; + + @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()) + .pixelData(request.getPixelData()) + .width(request.getWidth()) + .height(request.getHeight()) + .client(client) + .build(); + + Art savedArt = artRepository.save(art); + return ArtResponse.from(savedArt); + } + + public Page searchArts(ArtSearchCondition condition, ClientInfo clientInfo, Pageable pageable) { + Long currentClientId = clientInfo != null ? clientInfo.getClientId() : null; + + ArtQueryCondition queryCondition = ArtQueryCondition.builder() + .keyword(condition.getKeyword()) + .sortBy(condition.getSortBy()) + .sortDirection(condition.getSortDirection()) + .filterType(condition.getFilterType()) + .currentClientId(currentClientId) + .build(); + + Page arts = artRepository.searchArts(queryCondition, pageable); + + 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); + }); + } + + @Transactional + public ArtResponse getArt(ClientInfo clientInfo, Long artId) { + Art art = artRepository.findById(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + Client client = clientInfo != null ? clientManager.findById(clientInfo.getClientId()) : null; + + // 조회수 증가: 이미 조회한 사용자가 아닌 경우에만 + if (client != null && !artViewRepository.existsByArtAndClient(art, client)) { + ArtView artView = ArtView.builder() + .art(art) + .client(client) + .build(); + artViewRepository.save(artView); + artRepository.incrementViewCount(artId); + } + + // 좋아요 여부 및 소유 여부 확인 + if (client == null) { + return ArtResponse.from(art, false, false); + } + + boolean isLiked = artLikeRepository.existsByArtAndClient(art, client); + boolean isOwned = art.getClient().getId().equals(client.getId()); + + return ArtResponse.from(art, isLiked, isOwned); + } + + @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 ValidateException(ErrorCode.FORBIDDEN); + } + this.validatePixelData(request.getPixelData(), art.getWidth(), art.getHeight()); + art.update(request.getTitle(), request.getPixelData()); + + 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); + } + + @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(); + } + + public Page getComments(Long artId, ClientInfo clientInfo, Pageable pageable) { + Art art = artRepository.findById(artId).orElseThrow(() -> new GlobalException(ErrorCode.RESOURCE_NOT_FOUND)); + Page comments = artCommentRepository.findByArtOrderByCreatedAtAsc(art, pageable); + + return comments.map(comment -> { + if (comment.getClient().getId().equals(clientInfo.getClientId())) { + return ArtCommentResponse.from(comment, true); + } + return ArtCommentResponse.from(comment, false); + }); + } + + @Transactional + public ArtCommentResponse createComment(Long artId, ArtCommentRequest request, ClientInfo clientInfo) { + Art art = artRepository.findById(artId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + Client client = clientManager.findById(clientInfo.getClientId()); + + ArtComment comment = ArtComment.builder() + .content(request.getContent()) + .art(art) + .client(client) + .build(); + + ArtComment savedComment = artCommentRepository.save(comment); + artRepository.incrementCommentCount(artId); + return ArtCommentResponse.from(savedComment, true); + } + + @Transactional + public ArtCommentResponse updateComment(Long commentId, ArtCommentRequest request, ClientInfo clientInfo) { + ArtComment comment = artCommentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR)); + + if (!comment.getClient().getId().equals(clientInfo.getClientId())) { + throw new GlobalException(ErrorCode.FORBIDDEN); + } + + comment.updateContent(request.getContent()); + return ArtCommentResponse.from(comment, true); + } + + @Transactional + public void deleteComment(Long commentId, ClientInfo clientInfo) { + ArtComment comment = artCommentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RESOURCE_NOT_FOUND)); + + if (!comment.getClient().getId().equals(clientInfo.getClientId())) { + throw new GlobalException(ErrorCode.FORBIDDEN); + } + + Long artId = comment.getArt().getId(); + artCommentRepository.delete(comment); + artRepository.decrementCommentCount(artId); + } + + 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/entity/Art.java b/src/main/java/com/example/ticketing/art/domain/entity/Art.java new file mode 100644 index 0000000..f86bd5b --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/entity/Art.java @@ -0,0 +1,74 @@ +package com.example.ticketing.art.domain.entity; + +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) + 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 Integer commentCount = 0; + + @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 update(String title, String pixelData) { + this.title = title; + this.pixelData = pixelData; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/entity/ArtComment.java b/src/main/java/com/example/ticketing/art/domain/entity/ArtComment.java new file mode 100644 index 0000000..9c1dea6 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/entity/ArtComment.java @@ -0,0 +1,46 @@ +package com.example.ticketing.art.domain.entity; + +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/entity/ArtLike.java b/src/main/java/com/example/ticketing/art/domain/entity/ArtLike.java new file mode 100644 index 0000000..38bcef9 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/entity/ArtLike.java @@ -0,0 +1,36 @@ +package com.example.ticketing.art.domain.entity; + +import com.example.ticketing.art.domain.entity.Art; +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/entity/ArtView.java b/src/main/java/com/example/ticketing/art/domain/entity/ArtView.java new file mode 100644 index 0000000..947c0e2 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/entity/ArtView.java @@ -0,0 +1,35 @@ +package com.example.ticketing.art.domain.entity; + +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 ArtView { + + @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; +} diff --git a/src/main/java/com/example/ticketing/art/domain/enums/ArtFilterType.java b/src/main/java/com/example/ticketing/art/domain/enums/ArtFilterType.java new file mode 100644 index 0000000..7dc843a --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/enums/ArtFilterType.java @@ -0,0 +1,7 @@ +package com.example.ticketing.art.domain.enums; + +public enum ArtFilterType { + ONLY_MINE, + POPULAR, + HOT; +} diff --git a/src/main/java/com/example/ticketing/art/domain/enums/ArtSortType.java b/src/main/java/com/example/ticketing/art/domain/enums/ArtSortType.java new file mode 100644 index 0000000..65a5fa7 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/enums/ArtSortType.java @@ -0,0 +1,8 @@ +package com.example.ticketing.art.domain.enums; + +public enum ArtSortType { + LATEST, + LIKE, + VIEW, + COMMENT; +} diff --git a/src/main/java/com/example/ticketing/art/domain/enums/SortDirection.java b/src/main/java/com/example/ticketing/art/domain/enums/SortDirection.java new file mode 100644 index 0000000..ebaa304 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/enums/SortDirection.java @@ -0,0 +1,6 @@ +package com.example.ticketing.art.domain.enums; + +public enum SortDirection { + ASC, + DESC; +} diff --git a/src/main/java/com/example/ticketing/art/domain/repository/ArtCommentRepository.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtCommentRepository.java new file mode 100644 index 0000000..2150e88 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtCommentRepository.java @@ -0,0 +1,21 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.art.domain.entity.ArtComment; +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 findByArtOrderByCreatedAtAsc(Art art, Pageable pageable); + + List findByArtOrderByCreatedAtAsc(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/repository/ArtLikeRepository.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtLikeRepository.java new file mode 100644 index 0000000..e289e5d --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtLikeRepository.java @@ -0,0 +1,19 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.art.domain.entity.ArtLike; +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/repository/ArtQueryCondition.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtQueryCondition.java new file mode 100644 index 0000000..16461aa --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtQueryCondition.java @@ -0,0 +1,17 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.enums.ArtFilterType; +import com.example.ticketing.art.domain.enums.ArtSortType; +import com.example.ticketing.art.domain.enums.SortDirection; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ArtQueryCondition { + private String keyword; + private ArtSortType sortBy; + private SortDirection sortDirection; + private ArtFilterType filterType; + private Long currentClientId; +} diff --git a/src/main/java/com/example/ticketing/art/domain/repository/ArtRepository.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepository.java new file mode 100644 index 0000000..94b4510 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepository.java @@ -0,0 +1,34 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.client.domain.Client; +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; + +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); + + @Modifying + @Query("UPDATE Art a SET a.commentCount = a.commentCount + 1 WHERE a.id = :id") + void incrementCommentCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Art a SET a.commentCount = a.commentCount - 1 WHERE a.id = :id AND a.commentCount > 0") + void decrementCommentCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Art a SET a.viewCount = a.viewCount + 1 WHERE a.id = :id") + void incrementViewCount(@Param("id") Long id); +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/art/domain/repository/ArtRepositoryCustom.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepositoryCustom.java new file mode 100644 index 0000000..6ffb28c --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +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/repository/ArtRepositoryImpl.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepositoryImpl.java new file mode 100644 index 0000000..4e5d797 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtRepositoryImpl.java @@ -0,0 +1,151 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.art.domain.enums.ArtFilterType; +import com.example.ticketing.art.domain.enums.ArtSortType; +import com.example.ticketing.art.domain.enums.SortDirection; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static com.example.ticketing.art.domain.entity.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) { + // POPULAR 필터인 경우 특별 처리 + if (condition.getFilterType() == ArtFilterType.POPULAR) { + return searchPopularArts(condition, pageable); + } + + // TODAY_HOT 필터인 경우 특별 처리 + if (condition.getFilterType() == ArtFilterType.HOT) { + return searchHotArts(condition, pageable); + } + + List content = queryFactory + .selectFrom(art) + .leftJoin(art.client, client).fetchJoin() + .where( + keywordCondition(condition.getKeyword()), + filterCondition(condition.getFilterType(), 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()), + filterCondition(condition.getFilterType(), condition.getCurrentClientId()) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + private Page searchPopularArts(ArtQueryCondition condition, Pageable pageable) { + // 인기 점수 계산: (좋아요 * 3) + (댓글 * 2) + (조회수 * 1) + // 상위 10개만 반환 (페이지네이션 무시) + NumberExpression popularityScore = art.likeCount.multiply(3) + .add(art.commentCount.multiply(2)) + .add(art.viewCount); + + List content = queryFactory + .selectFrom(art) + .leftJoin(art.client, client).fetchJoin() + .where(keywordCondition(condition.getKeyword())) + .orderBy(popularityScore.desc(), art.createdAt.desc()) + .limit(10) + .fetch(); + + long total = content.size(); + + return PageableExecutionUtils.getPage(content, pageable, () -> total); + } + + private Page searchHotArts(ArtQueryCondition condition, Pageable pageable) { + // 최근 7일 이내 작품 중 인기 있는 작품 상위 10개만 반환 (페이지네이션 무시) + LocalDateTime weekAgo = LocalDateTime.now().minusDays(7); + NumberExpression popularityScore = art.likeCount.multiply(3) + .add(art.commentCount.multiply(2)) + .add(art.viewCount); + + List content = queryFactory + .selectFrom(art) + .leftJoin(art.client, client).fetchJoin() + .where( + keywordCondition(condition.getKeyword()), + art.createdAt.after(weekAgo) + ) + .orderBy(popularityScore.desc(), art.createdAt.desc()) + .limit(10) + .fetch(); + + long total = content.size(); + + return PageableExecutionUtils.getPage(content, pageable, () -> total); + } + + private BooleanExpression keywordCondition(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return art.title.containsIgnoreCase(keyword) + .or(art.client.name.containsIgnoreCase(keyword)); + } + + private BooleanExpression filterCondition(ArtFilterType filterType, Long currentClientId) { + if (filterType == null) { + return null; + } + + if (filterType == ArtFilterType.ONLY_MINE) { + return currentClientId != null ? art.client.id.eq(currentClientId) : null; + } + + return null; + } + + private OrderSpecifier[] getOrderSpecifiers(ArtSortType sortBy, SortDirection sortDirection) { + List> orderSpecifiers = new ArrayList<>(); + boolean isAsc = sortDirection == SortDirection.ASC; + + switch (sortBy) { + case LATEST -> orderSpecifiers.add(isAsc ? art.createdAt.asc() : art.createdAt.desc()); + case LIKE -> { + orderSpecifiers.add(isAsc ? art.likeCount.asc() : art.likeCount.desc()); + orderSpecifiers.add(art.createdAt.desc()); + } + case VIEW -> { + orderSpecifiers.add(isAsc ? art.viewCount.asc() : art.viewCount.desc()); + orderSpecifiers.add(art.createdAt.desc()); + } + case COMMENT -> { + orderSpecifiers.add(isAsc ? art.commentCount.asc() : art.commentCount.desc()); + orderSpecifiers.add(art.createdAt.desc()); + } + } + + return orderSpecifiers.toArray(new OrderSpecifier[0]); + } +} diff --git a/src/main/java/com/example/ticketing/art/domain/repository/ArtViewRepository.java b/src/main/java/com/example/ticketing/art/domain/repository/ArtViewRepository.java new file mode 100644 index 0000000..63ee742 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/domain/repository/ArtViewRepository.java @@ -0,0 +1,11 @@ +package com.example.ticketing.art.domain.repository; + +import com.example.ticketing.art.domain.entity.Art; +import com.example.ticketing.art.domain.entity.ArtView; +import com.example.ticketing.client.domain.Client; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArtViewRepository extends JpaRepository { + + boolean existsByArtAndClient(Art art, Client client); +} diff --git a/src/main/java/com/example/ticketing/art/dto/ArtCommentRequest.java b/src/main/java/com/example/ticketing/art/dto/ArtCommentRequest.java new file mode 100644 index 0000000..7b1301a --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtCommentRequest.java @@ -0,0 +1,13 @@ +package com.example.ticketing.art.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ArtCommentRequest { + + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 500자 이하로 입력해주세요.") + private String content; +} diff --git a/src/main/java/com/example/ticketing/art/dto/ArtCommentResponse.java b/src/main/java/com/example/ticketing/art/dto/ArtCommentResponse.java new file mode 100644 index 0000000..8dfa4cd --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtCommentResponse.java @@ -0,0 +1,32 @@ +package com.example.ticketing.art.dto; + +import com.example.ticketing.art.domain.entity.ArtComment; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class ArtCommentResponse { + + private Long id; + private String content; + private String authorName; + private Long authorId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean isOwnedByCurrentUser; + + public static ArtCommentResponse from(ArtComment comment, Boolean isOwnedByCurrentUser) { + return ArtCommentResponse.builder() + .id(comment.getId()) + .content(comment.getContent()) + .authorName(comment.getClient().getName() != null ? comment.getClient().getName() : "익명") + .authorId(comment.getClient().getId()) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .isOwnedByCurrentUser(isOwnedByCurrentUser) + .build(); + } +} 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..1ace82b --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtCreateRequest.java @@ -0,0 +1,26 @@ +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 = 20, message = "제목은 20자 이하로 입력해주세요.") + private String title; + + @NotBlank(message = "픽셀 데이터를 입력해주세요.") + private String pixelData; + + @NotNull(message = "가로 크기를 입력해주세요.") + @Positive(message = "가로 크기는 양수여야 합니다.") + private Integer width; + + @NotNull(message = "세로 크기를 입력해주세요.") + @Positive(message = "세로 크기는 양수여야 합니다.") + private Integer height; +} \ 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 new file mode 100644 index 0000000..e611065 --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtResponse.java @@ -0,0 +1,84 @@ +package com.example.ticketing.art.dto; + +import com.example.ticketing.art.domain.entity.Art; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class ArtResponse { + + private Long id; + private String title; + private String pixelData; + private Integer width; + private Integer height; + private Integer likeCount; + private Integer viewCount; + private Integer commentCount; + private String authorName; + private Long authorId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean isLikedByCurrentUser; + private Boolean isOwnedByCurrentUser; + + public static ArtResponse from(Art art) { + return ArtResponse.builder() + .id(art.getId()) + .title(art.getTitle()) + .pixelData(art.getPixelData()) + .width(art.getWidth()) + .height(art.getHeight()) + .likeCount(art.getLikeCount()) + .viewCount(art.getViewCount()) + .commentCount(art.getCommentCount()) + .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") + .authorId(art.getClient().getId()) + .createdAt(art.getCreatedAt()) + .updatedAt(art.getUpdatedAt()) + .isLikedByCurrentUser(false) + .isOwnedByCurrentUser(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()) + .commentCount(art.getCommentCount()) + .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") + .authorId(art.getClient().getId()) + .createdAt(art.getCreatedAt()) + .updatedAt(art.getUpdatedAt()) + .isLikedByCurrentUser(isLiked) + .isOwnedByCurrentUser(false) + .build(); + } + + public static ArtResponse from(Art art, boolean isLiked, boolean isOwned) { + return ArtResponse.builder() + .id(art.getId()) + .title(art.getTitle()) + .pixelData(art.getPixelData()) + .width(art.getWidth()) + .height(art.getHeight()) + .likeCount(art.getLikeCount()) + .viewCount(art.getViewCount()) + .commentCount(art.getCommentCount()) + .authorName(art.getClient().getName() != null ? art.getClient().getName() : "익명") + .authorId(art.getClient().getId()) + .createdAt(art.getCreatedAt()) + .updatedAt(art.getUpdatedAt()) + .isLikedByCurrentUser(isLiked) + .isOwnedByCurrentUser(isOwned) + .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..baa71fd --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtSearchCondition.java @@ -0,0 +1,14 @@ +package com.example.ticketing.art.dto; + +import com.example.ticketing.art.domain.enums.ArtFilterType; +import com.example.ticketing.art.domain.enums.ArtSortType; +import com.example.ticketing.art.domain.enums.SortDirection; +import lombok.Data; + +@Data +public class ArtSearchCondition { + private String keyword; + private ArtSortType sortBy = ArtSortType.LATEST; + private SortDirection sortDirection = SortDirection.DESC; + private ArtFilterType filterType; +} 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..ed781ba --- /dev/null +++ b/src/main/java/com/example/ticketing/art/dto/ArtUpdateRequest.java @@ -0,0 +1,16 @@ +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 = 20, message = "제목은 20자 이하로 입력해주세요.") + private String title; + + @NotBlank(message = "픽셀 데이터를 입력해주세요.") + private String pixelData; +} \ No newline at end of file diff --git a/src/main/java/com/example/ticketing/common/exception/ErrorCode.java b/src/main/java/com/example/ticketing/common/exception/ErrorCode.java index 0711ac6..77a2626 100644 --- a/src/main/java/com/example/ticketing/common/exception/ErrorCode.java +++ b/src/main/java/com/example/ticketing/common/exception/ErrorCode.java @@ -15,7 +15,9 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(500, "S01", "서버 통신 에러가 발생하였습니다. 잠시 후 다시 이용해주세요."), RESOURCE_NOT_FOUND(404, "R01", "존재하지 않는 리소스입니다."), - INAPPROPRIATE_CONTENT(400, "V01", "부적절한 내용이 포함되어 있습니다.") + INAPPROPRIATE_CONTENT(400, "V01", "부적절한 내용이 포함되어 있습니다."), + + FORBIDDEN(403, "G01", "접근 권한이 없습니다."), ; diff --git a/src/main/java/com/example/ticketing/config/WebConfig.java b/src/main/java/com/example/ticketing/config/WebConfig.java index 203a847..8bd5aa7 100644 --- a/src/main/java/com/example/ticketing/config/WebConfig.java +++ b/src/main/java/com/example/ticketing/config/WebConfig.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -31,7 +33,14 @@ public void addCorsMappings(CorsRegistry registry) { @Override public void addArgumentResolvers(List resolvers) { + // 토큰 인증 resolvers.add(new ClientInfoArgumentResolver(clientRepository)); + + // Pageable 최대 size 크기 제한 + PageableHandlerMethodArgumentResolver pageableResolver = new PageableHandlerMethodArgumentResolver(); + pageableResolver.setMaxPageSize(100); + pageableResolver.setFallbackPageable(PageRequest.of(0, 20)); + resolvers.add(pageableResolver); } }; } diff --git a/src/main/java/com/example/ticketing/view/ViewController.java b/src/main/java/com/example/ticketing/view/ViewController.java index cf8abd3..4327b86 100644 --- a/src/main/java/com/example/ticketing/view/ViewController.java +++ b/src/main/java/com/example/ticketing/view/ViewController.java @@ -39,4 +39,29 @@ 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) { + model.addAttribute("isEdit", false); + model.addAttribute("artId", null); + return "art/create"; + } + + @GetMapping("/art/edit/{id}") + public String artEdit(@PathVariable("id") Long id, Model model) { + model.addAttribute("isEdit", true); + model.addAttribute("artId", id); + 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/ads.txt b/src/main/resources/static/ads.txt new file mode 100644 index 0000000..b30e534 --- /dev/null +++ b/src/main/resources/static/ads.txt @@ -0,0 +1 @@ +google.com, pub-5146591484722882, DIRECT, f08c47fec0942fa0 \ No newline at end of file diff --git a/src/main/resources/static/css/art/create.css b/src/main/resources/static/css/art/create.css new file mode 100644 index 0000000..a543c4c --- /dev/null +++ b/src/main/resources/static/css/art/create.css @@ -0,0 +1,286 @@ +/* 데스크톱 스타일 */ +@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: 90%; + max-height: 100%; + overflow: scroll; + grid-template-columns: 1fr 2fr 1fr; + } + + #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; + } + + #create-header-section { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + } + + #header-title { + color: darkslateblue; + font-size: 25px; + margin: 0; + } + + #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; + } + + #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; + } + + #canvas-section { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + flex-shrink: 0; + } + + #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; + } + + #pixel-canvas { + border-radius: 8px; + cursor: cell; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } + + #title-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + } + + #title-section label { + font-weight: bold; + color: #333; + font-family: my-font, sans-serif; + } + + #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; + } + + #title:focus { + border-color: #514897; + } + + #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; + } + + #submit-btn:hover { + background: rgba(72, 61, 139, 0.68); + } +} + +/* 모바일 스타일 */ +@media (max-width: 768px) { + html, body { + height: 100dvh; + width: 100vw; + margin: 0; + background: white; + font-family: my-font, sans-serif; + overflow: hidden; + display: grid; + grid-template-rows: 18fr 82fr; + } + + #content-section { + display: grid; + justify-items: center; + align-items: center; + width: 100vw; + height: 100%; + max-height: 100%; + overflow: hidden; + grid-template-rows: 1fr 9fr; + } + + #art-create-section { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + font-family: my-font, sans-serif; + overflow: hidden; + box-sizing: border-box; + } + + #create-header-section { + display: none; + } + + #create-content-section { + width: 100%; + height: 100%; + padding: 0 12px 12px 12px; + box-sizing: border-box; + overflow-y: auto; + display: flex; + align-items: flex-start; + justify-content: center; + } + + #art-form { + width: 100%; + max-width: 100%; + background: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + } + + #title-section { + display: flex; + flex-direction: column; + gap: 5px; + background: white; + padding: 10px; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + #title-section label { + font-weight: bold; + color: #333; + font-family: my-font, sans-serif; + font-size: 0.7rem; + } + + #title { + width: 100%; + padding: 8px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 0.6rem; + font-family: my-font, sans-serif; + box-sizing: border-box; + outline: none; + } + + #title:focus { + border-color: #514897; + } + + #canvas-section { + display: flex; + flex-direction: column; + gap: 10px; + flex-shrink: 0; + } + + #canvas-wrapper { + background: white; + border-radius: 10px; + padding: 12px; + box-shadow: 0 2px 8px 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; + max-width: 100%; + height: auto; + } + + #submit-btn { + width: 100%; + padding: 12px; + background: #514897; + color: white; + border: none; + border-radius: 10px; + font-size: 0.8rem; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + #submit-btn:active { + transform: scale(0.98); + } +} diff --git a/src/main/resources/static/css/art/detail.css b/src/main/resources/static/css/art/detail.css new file mode 100644 index 0000000..60cf232 --- /dev/null +++ b/src/main/resources/static/css/art/detail.css @@ -0,0 +1,760 @@ +@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 { + 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; + width: 100vw; + height: 100%; + max-height: 100%; + overflow: hidden; + grid-template-columns: 1fr 2fr 1fr; + } + + #art-detail-section { + display: flex; + width: 100%; + height: 100%; + font-family: my-font, sans-serif; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + } + + #detail-content-section { + width: 100%; + padding: 2% 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + } + + #art-fixed-section { + width: 100%; + max-width: 560px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 16px; + } + + #art-info-header { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 20px; + background: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + font-size: 0.9rem; + color: #333; + font-family: Pretendard, sans-serif; + } + + #info-top-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + #info-left { + display: flex; + align-items: center; + gap: 8px; + } + + #art-actions { + display: none; + align-items: center; + gap: 8px; + } + + .action-link { + color: #514897; + cursor: pointer; + font-weight: 600; + transition: color 0.2s; + } + + .action-link:hover { + color: #6c5eb5; + text-decoration: underline; + } + + #info-bottom-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + #art-stats { + display: flex; + align-items: center; + gap: 12px; + } + + .info-separator { + color: #ddd; + font-weight: bold; + } + + #art-author { + font-weight: bold; + color: rgb(28, 27, 32); + } + + #art-title { + color: #8e8e8e; + font-weight: 600; + } + + #created-at { + color: #8e8e8e; + font-size: 0.85rem; + margin-left: auto; + } + + #art-likes, + #art-comments, + #art-views { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + } + + #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-display { + width: 100%; + background: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + } + + #canvas-wrapper { + display: flex; + justify-content: center; + align-items: center; + background: white; + border-radius: 12px; + max-width: 100%; + overflow: auto; + } + + #art-canvas { + border-radius: 8px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } + + #comments-section { + width: 100%; + max-width: 560px; + background: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + display: flex; + flex-direction: column; + margin-bottom: 24px; + } + + #comments-title { + font-size: 1.1rem; + font-weight: bold; + color: #333; + margin: 0 0 16px 0; + font-family: my-font, sans-serif; + flex-shrink: 0; + } + + #comment-form { + display: flex; + gap: 12px; + margin-bottom: 24px; + flex-shrink: 0; + } + + #comment-input { + flex: 1; + padding: 12px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 14px; + font-family: my-font, sans-serif; + resize: vertical; + min-height: 60px; + outline: none; + } + + #comment-input:focus { + border-color: #514897; + } + + #comment-submit-btn { + padding: 12px 24px; + background: #514897; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s; + align-self: flex-end; + } + + #comment-submit-btn:hover { + background: rgba(72, 61, 139, 0.68); + } + + #comments-list { + display: flex; + flex-direction: column; + gap: 16px; + } + + .comments-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + color: #8e8e8e; + font-size: 0.95rem; + font-family: my-font, sans-serif; + text-align: center; + } + + .comment-item { + padding: 12px; + background: #f8f9fa; + border-radius: 8px; + } + + .comment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .comment-author { + font-weight: bold; + font-size: 0.9rem; + color: #333; + font-family: my-font, sans-serif; + } + + .comment-actions { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + } + + .comment-edit-link, + .comment-delete-link { + color: #514897; + cursor: pointer; + font-weight: 600; + font-family: my-font, sans-serif; + transition: color 0.2s; + } + + .comment-edit-link:hover, + .comment-delete-link:hover { + color: #6c5eb5; + text-decoration: underline; + } + + .comment-separator { + color: #ddd; + font-weight: bold; + } + + .comment-body { + display: flex; + flex-direction: column; + gap: 8px; + } + + .comment-content-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + } + + .comment-date { + font-size: 0.75rem; + color: #8e8e8e; + font-family: my-font, sans-serif; + } + + .comment-content { + font-size: 0.8rem; + color: #333; + line-height: 1.5; + font-weight: bold; + font-family: Pretendard, sans-serif; + word-break: break-word; + margin: 0; + } + + .comment-edit-input { + padding: 8px; + border: 1px solid #dbdbdb; + border-radius: 6px; + font-size: 0.6rem; + font-family: my-font, sans-serif; + resize: vertical; + min-height: 60px; + outline: none; + } + + .comment-edit-input:focus { + border-color: #514897; + } + + .comment-edit-actions { + display: none; + gap: 6px; + align-items: center; + } + + .comment-save-btn, + .comment-cancel-btn { + padding: 6px 12px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s; + } + + .comment-save-btn { + background: #514897; + color: white; + } + + .comment-save-btn:hover { + background: #6c5eb5; + } + + .comment-cancel-btn { + background: #6c757d; + color: white; + } + + .comment-cancel-btn:hover { + background: #5a6268; + } +} + + + + + + + + +/* 모바일 스타일 */ +@media (max-width: 768px) { + html, body { + height: 100dvh; + width: 100vw; + margin: 0; + background: white; + font-family: my-font, sans-serif; + overflow: hidden; + display: grid; + grid-template-rows: 18fr 82fr; + } + + #content-section { + display: grid; + justify-items: center; + width: 100vw; + height: 100%; + max-height: 100%; + overflow: hidden; + grid-template-rows: 1fr 9fr; + } + + #art-detail-section { + width: 100%; + height: 100%; + display: flex; + font-family: my-font, sans-serif; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + } + + #detail-content-section { + width: 100%; + padding: 12px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 12px; + } + + #art-fixed-section { + width: 100%; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + + #art-info-header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: white; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-size: 0.75rem; + color: #333; + font-family: Pretendard, sans-serif; + } + + #info-top-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + } + + #art-actions { + display: none; + align-items: center; + gap: 5px; + } + + .action-link { + color: #514897; + cursor: pointer; + font-weight: 600; + font-size: 0.7rem; + transition: color 0.2s; + } + + #info-bottom-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + } + + #art-stats { + display: flex; + align-items: center; + gap: 8px; + } + + #art-stats svg { + width: 16px; + height: 16px; + } + + .info-separator { + color: #ddd; + font-weight: bold; + } + + #art-author { + font-weight: bold; + color: rgb(28, 27, 32); + font-size: 0.8rem; + } + + #art-title { + color: #8e8e8e; + font-weight: 600; + font-size: 0.75rem; + } + + #created-at { + color: #8e8e8e; + font-size: 0.7rem; + margin-left: auto; + } + + #art-likes, + #art-comments, + #art-views { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + font-size: 0.75rem; + } + + #art-views { + cursor: default; + } + + #art-likes svg path { + transition: all 0.2s ease; + } + + #art-display { + background: white; + padding: 12px; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + #canvas-wrapper { + display: flex; + justify-content: center; + align-items: center; + max-width: 100%; + overflow: auto; + } + + #art-canvas { + border-radius: 8px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + max-width: 100%; + height: auto; + } + + #comments-section { + background: white; + padding: 12px; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + margin-bottom: 12px; + } + + #comments-title { + font-size: 0.95rem; + font-weight: bold; + color: #333; + margin: 0 0 10px 0; + font-family: my-font, sans-serif; + flex-shrink: 0; + } + + #comment-form { + display: flex; + flex-direction: row; + gap: 6px; + margin-bottom: 12px; + flex-shrink: 0; + align-items: flex-end; + } + + #comment-input { + flex: 1; + padding: 8px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 0.7rem; + font-family: my-font, sans-serif; + resize: none; + outline: none; + box-sizing: border-box; + } + + #comment-input:focus { + border-color: #514897; + } + + #comment-submit-btn { + padding: 8px 12px; + background: #514897; + color: white; + border: none; + border-radius: 8px; + font-weight: bold; + font-size: 0.7rem; + font-family: my-font, sans-serif; + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; + } + + #comments-list { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; + min-height: 0; + } + + .comments-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 120px; + color: #8e8e8e; + font-size: 0.8rem; + font-family: my-font, sans-serif; + text-align: center; + } + + .comment-item { + padding: 10px; + background: #f8f9fa; + border-radius: 8px; + } + + .comment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .comment-author { + font-size: 0.7rem; + color: #333; + font-family: my-font, sans-serif; + } + + .comment-actions { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + } + + .comment-edit-link, + .comment-delete-link { + color: #514897; + cursor: pointer; + font-weight: 600; + font-family: my-font, sans-serif; + transition: color 0.2s; + } + + .comment-separator { + color: #ddd; + font-weight: bold; + } + + .comment-body { + display: flex; + flex-direction: column; + gap: 6px; + } + + .comment-content-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + } + + .comment-date { + font-size: 0.65rem; + color: #8e8e8e; + font-family: Pretendard, sans-serif; + } + + .comment-content { + font-size: 0.7rem; + font-weight: normal; + color: #333; + line-height: 1.4; + font-family: Pretendard, sans-serif; + word-break: break-word; + margin: 0; + } + + .comment-edit-input { + padding: 8px; + border: 1px solid #dbdbdb; + border-radius: 6px; + font-size: 0.7rem; + font-family: Pretendard, sans-serif; + resize: vertical; + min-height: 50px; + outline: none; + } + + .comment-edit-input:focus { + border-color: #514897; + } + + .comment-edit-actions { + display: none; + gap: 5px; + align-items: center; + } + + .comment-save-btn, + .comment-cancel-btn { + padding: 5px 10px; + border: none; + border-radius: 6px; + font-size: 0.7rem; + font-weight: bold; + font-family: my-font, sans-serif; + cursor: pointer; + transition: all 0.2s; + } + + .comment-save-btn { + background: #514897; + color: white; + } + + .comment-cancel-btn { + background: #6c757d; + color: white; + } +} diff --git a/src/main/resources/static/css/art/gallery.css b/src/main/resources/static/css/art/gallery.css new file mode 100644 index 0000000..6bd152a --- /dev/null +++ b/src/main/resources/static/css/art/gallery.css @@ -0,0 +1,1077 @@ +@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 { + 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: 3fr 7fr; + width: 100%; + height: 100%; + max-height: 100%; + align-items: center; + font-family: my-font, sans-serif; + overflow: hidden; + box-sizing: border-box; + row-gap: 3%; + } + + #gallery-header-section { + display: grid; + grid-template-rows: 1fr 1fr; + text-align: center; + align-items: center; + justify-items: center; + height: 100%; + width: 100%; + max-height: 100%; + box-sizing: border-box; + } + + #gallery-title { + color: darkslateblue; + font-size: 25px; + text-align: center; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + } + + #gallery-content { + flex: 1; + width: 100%; + height: 100%; + overflow-y: scroll; + display: flex; + flex-direction: column; + align-items: center; + } + + #gallery-controls { + width: 90%; + max-width: 1000px; + padding: 2%; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + } + + #controls-top-row { + display: grid; + grid-template-columns: 8fr 2fr; + column-gap: 5%; + } + + #controls-bottom-row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + #filter-buttons { + display: flex; + gap: 10px; + flex: 1; + } + + .glass-filter-btn { + padding: 12px 24px; + background: linear-gradient(135deg, + rgba(255, 255, 255, 0.3) 0%, + rgba(255, 255, 255, 0.1) 100%); + backdrop-filter: blur(40px) saturate(200%); + -webkit-backdrop-filter: blur(40px) saturate(200%); + border: 1.5px solid rgba(255, 255, 255, 0.5); + border-top-color: rgba(255, 255, 255, 0.7); + border-left-color: rgba(255, 255, 255, 0.7); + border-radius: 20px; + color: #2c2c2c; + font-family: my-font, sans-serif; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 8px 32px 0 rgba(31, 38, 135, 0.2), + inset 0 1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.2); + white-space: nowrap; + position: relative; + overflow: hidden; + } + + .glass-filter-btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 50%; + background: linear-gradient(180deg, + rgba(255, 255, 255, 0.2) 0%, + transparent 100%); + border-radius: 20px 20px 0 0; + pointer-events: none; + } + + .glass-filter-btn::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent); + transition: left 0.6s; + } + + .glass-filter-btn:hover::after { + left: 100%; + } + + .glass-filter-btn:hover { + background: linear-gradient(135deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0.1) 100%); + border-color: rgba(255, 255, 255, 0.35); + border-top-color: rgba(255, 255, 255, 0.5); + border-left-color: rgba(255, 255, 255, 0.5); + transform: translateY(-2px); + box-shadow: + 0 12px 40px 0 rgba(31, 38, 135, 0.2), + inset 0 1px 0 0 rgba(255, 255, 255, 0.6), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.15); + } + + .glass-filter-btn.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + color: white; + font-weight: 700; + transform: scale(1.03); + box-shadow: + 0 4px 12px rgba(123, 133, 232, 0.35), + 0 0 8px rgba(123, 133, 232, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); + } + + .glass-filter-btn.active:hover { + background: linear-gradient(135deg, #6a75dd 0%, #7c5aa4 100%); + transform: scale(1.03) translateY(-1px); + box-shadow: + 0 6px 16px rgba(123, 133, 232, 0.45), + 0 0 12px rgba(123, 133, 232, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); + } + + #search-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; + } + + #search-input { + flex: 1; + min-width: 200px; + padding: 10px 45px 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; + } + + #search-btn { + position: absolute; + right: 10px; + background: none; + border: none; + cursor: pointer; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; + color: #8e8e8e; + transition: color 0.2s; + } + + #search-btn:hover { + color: #514897; + } + + #sort-wrapper { + display: flex; + gap: 0; + } + + #sort-select-type { + position: relative; + user-select: none; + overflow: visible; + } + + #sort-trigger { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 15px; + border: 1px solid #dbdbdb; + border-radius: 8px 0 0 8px; + background: white; + cursor: pointer; + transition: all 0.2s ease; + box-sizing: border-box; + min-width: 100px; + } + + #sort-trigger:hover { + border-color: #514897; + } + + #sort-trigger.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + color: white; + font-weight: 700; + box-shadow: + 0 4px 12px rgba(123, 133, 232, 0.35), + 0 0 8px rgba(123, 133, 232, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); + } + + #sort-trigger.active.dropdown-open { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + #sort-value { + font-family: my-font, sans-serif; + color: #333; + font-size: 0.95rem; + } + + #sort-trigger.active #sort-value { + color: white; + } + + #sort-options { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #514897; + border-top: none; + border-radius: 0 0 8px 8px; + list-style: none; + margin: 0; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + visibility: hidden; + } + + #sort-options.active { + max-height: 200px; + overflow-y: auto; + visibility: visible; + } + + #sort-options li { + padding: 10px 15px; + cursor: pointer; + font-family: my-font, sans-serif; + color: #333; + font-size: 0.95rem; + transition: all 0.2s ease; + text-align: center; + } + + #sort-options li:hover { + background: #f5f5f5; + color: #514897; + } + + #sort-options li.selected { + background: #514897; + color: white; + } + + #sort-options li.selected:hover { + background: rgba(81, 72, 151, 0.8); + color: white; + } + + #direction-toggle { + padding: 10px; + border: 1px solid #dbdbdb; + border-left: none; + border-radius: 0 8px 8px 0; + background: white; + cursor: pointer; + outline: none; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + #direction-toggle:hover { + background: #837db9; + } + + #direction-toggle.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + border-left: none; + color: white; + box-shadow: + 0 4px 12px rgba(123, 133, 232, 0.35), + 0 0 8px rgba(123, 133, 232, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); + } + + #direction-toggle.active svg { + color: white; + } + + #direction-toggle[data-direction="desc"] { + color: #262626; + } + + #direction-toggle[data-direction="asc"] { + color: #262626; + } + + #create-art-btn { + padding: 12px 28px; + background: linear-gradient(135deg, #6b5b9a 0%, #8470b3 100%); + color: white; + border: none; + border-radius: 24px; + font-family: my-font, sans-serif; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + white-space: nowrap; + letter-spacing: 0.5px; + box-sizing: border-box; + box-shadow: 0 4px 12px rgba(107, 91, 154, 0.4); + animation: subtlePulse 2s ease-in-out infinite; + flex-shrink: 0; + min-width: fit-content; + } + + @keyframes subtlePulse { + 0%, 100% { + box-shadow: 0 4px 12px rgba(107, 91, 154, 0.4); + } + 50% { + box-shadow: 0 6px 16px rgba(107, 91, 154, 0.6); + } + } + + #create-art-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + transition: left 0.5s; + } + + #create-art-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(107, 91, 154, 0.5); + animation: none; + } + + #create-art-btn:hover::before { + left: 100%; + } + + .art-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: 40px; + width: 100%; + padding: 0 5%; + box-sizing: border-box; + align-items: center; + justify-items: center; + } + + .gallery-empty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + color: #8e8e8e; + font-size: 1.1rem; + font-family: my-font, sans-serif; + text-align: center; + } + + /* 아트 카드 - 인스타 스타일 */ + .art-card { + display: flex; + flex-direction: column; + background: none; + border: 1px solid #dbdbdb; + border-radius: 16px; + box-sizing: border-box; + overflow: hidden; + max-width: 500px; + } + + .art-card:hover { + transform: scale(1.01); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); + } + + .art-preview { + position: relative; + background: #fafafa; + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1; + overflow: hidden; + border: 1px solid #dbdbdb; + cursor: pointer; + } + + .art-caption, .art-footer { + cursor: pointer; + } + + .art-canvas { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } + + .art-info { + padding: 10px 14px; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + align-items: center; + justify-items: left; + row-gap: 10%; + } + + .art-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + width: 50%; + } + + .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-caption { + width: 100%; + display: flex; + align-items: center; + margin: 0; + overflow: hidden; + gap: 3%; + font-family: Pretendard, sans-serif; + } + + .art-author { + 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 { + text-align: center; + padding: 50px; + color: #666; + font-size: 1.2rem; + font-family: my-font, sans-serif; + width: 100%; + } +} + + + + + + + +/* 모바일 스타일 */ +@media (max-width: 768px) { + html, body { + height: 100dvh; /* 화면 전체 높이 차지 */ + width: 100vw; + margin: 0; + background: white; + font-family: my-font, sans-serif; + overflow: hidden; + display: grid; + grid-template-rows: 18fr 82fr; + } + + #content-section { + display: grid; + justify-items: center; + align-items: center; + width: 100vw; + height: 100%; + max-height: 100%; + overflow: hidden; + grid-template-rows: 1fr 9fr; + } + + #art-gallery-section { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + box-sizing: border-box; + } + + #gallery-header-section { + flex-shrink: 0; + width: 100%; + padding: 0 12px 12px 12px; + box-sizing: border-box; + } + + #gallery-title { + display: none; + } + + #gallery-controls { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + } + + #controls-top-row { + display: flex; + gap: 6px; + } + + #search-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; + } + + #search-input { + width: 100%; + padding: 6px 32px 6px 8px; + border: 1px solid #dbdbdb; + border-radius: 8px; + font-size: 0.75rem; + font-family: my-font, sans-serif; + outline: none; + box-sizing: border-box; + } + + #search-input:focus { + border-color: #514897; + } + + #search-btn { + position: absolute; + right: 6px; + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #8e8e8e; + height: 100%; + } + + #search-btn svg { + width: 16px; + height: 16px; + } + + #sort-wrapper { + display: flex; + gap: 0; + flex-shrink: 0; + } + + #sort-select-type { + position: relative; + user-select: none; + overflow: visible; + } + + #sort-trigger { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + border: 1px solid #dbdbdb; + border-radius: 8px 0 0 8px; + background: white; + cursor: pointer; + transition: all 0.2s ease; + box-sizing: border-box; + min-width: 65px; + height: 100%; + } + + #sort-trigger:hover { + border-color: #514897; + } + + #sort-trigger.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + color: white; + font-weight: 700; + box-shadow: 0 3px 10px rgba(123, 133, 232, 0.35); + } + + #sort-trigger.active.dropdown-open { + border-bottom-left-radius: 0; + } + + #sort-value { + font-family: my-font, sans-serif; + color: #333; + font-size: 0.7rem; + } + + #sort-trigger.active #sort-value { + color: white; + } + + #sort-options { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #514897; + border-top: none; + border-radius: 0 0 8px 8px; + list-style: none; + margin: 0; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + visibility: hidden; + } + + #sort-options.active { + max-height: 200px; + overflow-y: auto; + visibility: visible; + } + + #sort-options li { + padding: 6px 8px; + cursor: pointer; + font-family: my-font, sans-serif; + color: #333; + font-size: 0.7rem; + transition: all 0.2s ease; + text-align: center; + } + + #sort-options li:hover { + background: #f5f5f5; + color: #514897; + } + + #sort-options li.selected { + background: #514897; + color: white; + } + + #direction-toggle { + padding: 6px; + border: 1px solid #dbdbdb; + border-left: none; + border-radius: 0 8px 8px 0; + background: white; + cursor: pointer; + outline: none; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + #direction-toggle:hover { + background: #f5f5f5; + } + + #direction-toggle.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + border-left: none; + color: white; + box-shadow: 0 3px 10px rgba(123, 133, 232, 0.35); + } + + #direction-toggle.active svg { + color: white; + } + + #direction-toggle svg { + width: 16px; + height: 16px; + } + + #controls-bottom-row { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + } + + #filter-buttons { + display: flex; + gap: 6px; + flex: 1; + } + + .glass-filter-btn { + padding: 6px 8px; + background: white; + border: 1px solid #dbdbdb; + border-radius: 12px; + color: #2c2c2c; + font-family: my-font, sans-serif; + font-size: 0.65rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + flex: 1; + } + + .glass-filter-btn:hover { + border-color: #514897; + } + + .glass-filter-btn.active { + background: linear-gradient(135deg, #7b85e8 0%, #8d6bb5 100%); + border: 2px solid #8a94ed; + color: white; + font-weight: 700; + transform: scale(1.03); + box-shadow: 0 3px 10px rgba(123, 133, 232, 0.35); + } + + .glass-filter-btn.active:hover { + background: linear-gradient(135deg, #6a75dd 0%, #7c5aa4 100%); + box-shadow: 0 4px 12px rgba(123, 133, 232, 0.45); + } + + #create-art-btn { + padding: 8px 16px; + background: linear-gradient(135deg, #6b5b9a 0%, #8470b3 100%); + color: white; + border: none; + border-radius: 16px; + font-family: my-font, sans-serif; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + box-shadow: 0 3px 10px rgba(107, 91, 154, 0.4); + animation: subtlePulse 2s ease-in-out infinite; + } + + #create-art-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + transition: left 0.5s; + } + + #create-art-btn:hover { + box-shadow: 0 4px 14px rgba(107, 91, 154, 0.5); + animation: none; + } + + #create-art-btn:hover::before { + left: 100%; + } + + #gallery-content { + flex: 1; + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; + } + + .art-gallery-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + width: 100%; + padding: 0 12px 12px 12px; + box-sizing: border-box; + justify-items: center; + } + + .gallery-empty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #8e8e8e; + font-size: 0.95rem; + font-family: my-font, sans-serif; + text-align: center; + } + + /* 아트 카드 */ + .art-card { + width: 90%; + display: flex; + flex-direction: column; + background: white; + border: 1px solid #dbdbdb; + border-radius: 10px; + overflow: hidden; + box-sizing: border-box; + } + + .art-preview { + position: relative; + background: #fafafa; + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1; + overflow: hidden; + cursor: pointer; + } + + .art-canvas { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } + + .art-info { + padding: 10px; + display: grid; + grid-template-rows: auto auto auto; + gap: 6px; + } + + .art-stats { + display: flex; + gap: 10px; + } + + .art-likes, .art-comments, .art-views { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.75rem; + font-weight: 600; + color: #262626; + font-family: my-font, sans-serif; + cursor: pointer; + } + + .art-likes svg, .art-comments svg, .art-views svg { + width: 18px; + height: 18px; + } + + .art-views { + cursor: default; + } + + .art-likes svg path { + transition: all 0.2s ease; + } + + .art-comments svg, .art-views svg { + fill: none; + stroke: currentColor; + } + + .art-caption { + display: flex; + align-items: center; + gap: 5px; + font-family: Pretendard, sans-serif; + cursor: pointer; + } + + .art-author { + font-weight: bold; + font-size: 0.85rem; + color: rgb(28, 27, 32); + } + + .art-title { + color: rgb(28, 27, 32); + font-size: 0.8rem; + font-weight: 400; + } + + .art-footer { + display: flex; + justify-content: flex-start; + cursor: pointer; + } + + .art-date { + font-size: 0.65rem; + color: #8e8e8e; + font-family: my-font, sans-serif; + white-space: nowrap; + } + + #gallery-loading, #gallery-no-more { + text-align: center; + padding: 20px; + color: #666; + font-size: 0.85rem; + font-family: my-font, sans-serif; + width: 100%; + } +} + 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..46057d3 --- /dev/null +++ b/src/main/resources/static/js/art/create.js @@ -0,0 +1,264 @@ +import * as util from "../common.js"; + +const CONFIG = { + gridSize: 30, + cellSize: 16, + gap: 2.5, + corner: 1.5, + colors: { + base: "#6633cc", + hover: "#7c4dff", + active: "#cbb5ff", + activeHover: "#e4daff", + border: "rgba(255,255,255,0.55)", + background: "#251b3c" + } +}; + +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.isEdit = IS_EDIT; + this.artId = ART_ID; + + this.bindEvents(); + this.init(); + } + + async init() { + if (this.isEdit && this.artId) { + await this.loadExistingArt(); + } + this.drawBoard(); + } + + async loadExistingArt() { + try { + const response = await util.authFetch(`${HOST}/api/arts/${this.artId}`, { + credentials: 'include' + }); + + if (!response.ok) throw new Error('작품을 불러올 수 없습니다.'); + + const artData = await response.json(); + + // 제목 설정 + const titleInput = this.form.querySelector("#title"); + if (titleInput) { + titleInput.value = artData.title; + } + + // 픽셀 데이터 로드 + if (artData.pixel_data) { + this.loadPixelData(artData.pixel_data); + } + + // 버튼 텍스트 변경 + const submitBtn = document.getElementById('submit-btn'); + if (submitBtn) { + submitBtn.textContent = '작품 수정'; + } + + // 헤더 제목 변경 + const headerTitle = document.getElementById('header-title'); + if (headerTitle) { + headerTitle.textContent = '포도아트 수정하기'; + } + } catch (error) { + console.error('작품 로딩 실패:', error); + alert('작품을 불러오는데 실패했습니다.'); + window.location.href = '/art'; + } + } + + loadPixelData(pixelData) { + if (!pixelData || typeof pixelData !== 'string') return; + + for (let i = 0; i < Math.min(pixelData.length, this.state.length); i++) { + this.state[i] = pixelData[i] === '1'; + } + } + + 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)); + + // title input 엔터키 처리 + const titleInput = this.form?.querySelector("#title"); + if (titleInput) { + titleInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + titleInput.blur(); // 키보드 내리기 + } + }); + } + } + + 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 scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + + // 클라이언트 좌표를 캔버스 좌표로 변환 + const canvasX = (clientX - rect.left) * scaleX; + const canvasY = (clientY - rect.top) * scaleY; + + const x = Math.floor(canvasX / this.cellSize); + const y = Math.floor(canvasY / 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); + + // 성능 최적화: roundRect 사용, 그림자 제거 + this.ctx.beginPath(); + this.ctx.roundRect(drawX, drawY, size, size, radius); + this.ctx.fillStyle = color; + this.ctx.fill(); + + if (isHover) { + this.ctx.lineWidth = 1.5; + this.ctx.strokeStyle = CONFIG.colors.border; + this.ctx.stroke(); + } + } + + 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(); + + if (!title) { + alert("제목을 입력해주세요."); + return; + } + + const pixelData = this.toPixelData(); + + // pixelData가 모두 0인지 확인 (아무것도 그리지 않은 경우) + if (!pixelData.includes('1')) { + alert("작품을 그려주세요."); + return; + } + + const artData = { + title, + pixel_data: pixelData, + width: this.gridSize, + height: this.gridSize + }; + + try { + const method = this.isEdit ? "PUT" : "POST"; + const url = this.isEdit ? `${HOST}/api/arts/${this.artId}` : `${HOST}/api/arts`; + + const response = await util.authFetch(url, { + method: method, + 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: "pixel-canvas", + resetId: "clear-btn", + formId: "art-form" + }); +}); 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..f7918a5 --- /dev/null +++ b/src/main/resources/static/js/art/detail.js @@ -0,0 +1,465 @@ +import {authFetch} from "../common.js"; + +class Detail { + constructor() { + this.artId = ART_ID; + this.artData = null; + + this.titleElement = document.getElementById('art-title'); + this.authorElement = document.getElementById('art-author'); + this.createdAtElement = document.getElementById('created-at'); + this.likeCountElement = document.getElementById('like-count'); + this.commentCountElement = document.getElementById('comment-count'); + this.viewCountElement = document.getElementById('view-count'); + this.artCanvas = document.getElementById('art-canvas'); + this.artActions = document.getElementById('art-actions'); + this.editBtn = document.getElementById('edit-btn'); + this.deleteBtn = document.getElementById('delete-btn'); + this.artLikesElement = document.getElementById('art-likes'); + + this.commentInput = document.getElementById('comment-input'); + this.commentSubmitBtn = document.getElementById('comment-submit-btn'); + this.commentsList = document.getElementById('comments-list'); + + // 무한스크롤 관련 변수 + this.currentPage = 0; + this.isLoadingComments = false; + this.hasMoreComments = true; + this.commentsSize = 20; + + this.init(); + } + + async init() { + await this.loadArt(); + await this.loadComments(true); + this.setupEventListeners(); + this.setupInfiniteScroll(); + } + + async loadArt() { + try { + const response = await authFetch(`${HOST}/api/arts/${this.artId}`, { + credentials: 'include' + }); + + if (response.ok) { + this.artData = await response.json(); + this.renderArt(); + this.updateUIBasedOnResponse(); + } else { + throw new Error('작품을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('작품 로딩 실패:', error); + alert('작품을 불러오는데 실패했습니다.'); + window.location.href = '/art'; + } + } + + renderArt() { + const { title, author_name, created_at, view_count, like_count, pixel_data, width, height } = this.artData; + + // 기본 정보 표시 + this.titleElement.textContent = title; + this.authorElement.textContent = author_name; + this.createdAtElement.textContent = this.formatDate(created_at); + this.likeCountElement.textContent = this.formatCount(like_count || 0); + this.commentCountElement.textContent = '0'; // 댓글 기능 미구현 + this.viewCountElement.textContent = this.formatCount(view_count || 0); + + // 캔버스 설정 및 렌더링 + this.artCanvas.width = 360; + this.artCanvas.height = 360; + + this.renderPixelArt(this.artCanvas, pixel_data, width, height); + } + + renderPixelArt(canvas, pixelData, gridWidth, gridHeight) { + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + + const cellSize = 12; + const gap = 2; + const corner = 1.5; + const innerSize = cellSize - gap; + const radius = Math.min(corner, innerSize / 2); + + // 배경 + ctx.fillStyle = '#251b3c'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + if (!pixelData || typeof pixelData !== 'string') { + return; + } + + // 성능 최적화: roundRect 사용 (그림자 제거) + // 비활성 픽셀 + ctx.fillStyle = '#6633cc'; + ctx.beginPath(); + for (let i = 0; i < pixelData.length; i++) { + if (pixelData[i] === '1') continue; // 활성은 스킵 + + const col = i % gridWidth; + const row = Math.floor(i / gridWidth); + const x = col * cellSize + gap / 2; + const y = row * cellSize + gap / 2; + ctx.roundRect(x, y, innerSize, innerSize, radius); + } + ctx.fill(); + + // 활성 픽셀 + ctx.fillStyle = '#cbb5ff'; + ctx.beginPath(); + for (let i = 0; i < pixelData.length; i++) { + if (pixelData[i] !== '1') continue; // 비활성은 스킵 + + const col = i % gridWidth; + const row = Math.floor(i / gridWidth); + const x = col * cellSize + gap / 2; + const y = row * cellSize + gap / 2; + ctx.roundRect(x, y, innerSize, innerSize, radius); + } + ctx.fill(); + } + + 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(); + } + + updateUIBasedOnResponse() { + // 소유 여부에 따라 수정/삭제 링크 표시 + const artActions = document.getElementById('art-actions'); + if (this.artData.is_owned_by_current_user && artActions) { + artActions.style.display = 'flex'; + } + + // 좋아요 여부에 따라 UI 업데이트 + if (this.artData.is_liked_by_current_user) { + const svgPath = this.artLikesElement.querySelector('svg path'); + svgPath.setAttribute('fill', '#ed4956'); + svgPath.setAttribute('stroke', '#ed4956'); + } + } + + setupEventListeners() { + // 좋아요 버튼 + if (this.artLikesElement) { + this.artLikesElement.addEventListener('click', () => this.toggleLike()); + } + + if (this.editBtn) { + this.editBtn.addEventListener('click', () => this.editArt()); + } + + if (this.deleteBtn) { + this.deleteBtn.addEventListener('click', () => this.deleteArt()); + } + + if (this.commentSubmitBtn) { + this.commentSubmitBtn.addEventListener('click', () => this.createComment()); + } + + if (this.commentInput) { + this.commentInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.createComment(); + } + }); + } + } + + setupInfiniteScroll() { + const artDetailSection = document.getElementById('art-detail-section'); + if (!artDetailSection) return; + + artDetailSection.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = artDetailSection; + + // 스크롤이 하단 100px 이내로 왔을 때 다음 페이지 로드 + if (scrollTop + clientHeight >= scrollHeight - 100) { + this.loadComments(); + } + }); + } + + async toggleLike() { + try { + const response = await authFetch(`${HOST}/api/arts/${this.artId}/like`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) throw new Error('좋아요 실패'); + + const result = await response.json(); + const isLiked = result.is_liked; + const likeCount = result.like_count; + + // UI 업데이트 + const svgPath = this.artLikesElement.querySelector('svg path'); + + if (isLiked) { + svgPath.setAttribute('fill', '#ed4956'); + svgPath.setAttribute('stroke', '#ed4956'); + } else { + svgPath.setAttribute('fill', 'none'); + svgPath.setAttribute('stroke', 'currentColor'); + } + + this.likeCountElement.textContent = this.formatCount(likeCount); + this.artData.is_liked_by_current_user = isLiked; + } catch (error) { + console.error('좋아요 토글 실패:', error); + alert('좋아요 처리에 실패했습니다.'); + } + } + + editArt() { + window.location.href = `/art/edit/${this.artId}`; + } + + async deleteArt() { + if (!confirm('정말로 이 작품을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) { + return; + } + + try { + const response = await authFetch(`${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); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${year}.${month}.${day} ${hours}:${minutes}`; + } + + async loadComments(isInitial = false) { + if (this.isLoadingComments || (!isInitial && !this.hasMoreComments)) return; + + this.isLoadingComments = true; + + try { + const response = await authFetch(`${HOST}/api/arts/${this.artId}/comments?page=${this.currentPage}&size=${this.commentsSize}`); + + if (response.ok) { + const data = await response.json(); + + if (isInitial) { + this.renderComments(data.content, true); + this.commentCountElement.textContent = data.total_elements || data.content.length; + } else { + this.renderComments(data.content, false); + } + + this.hasMoreComments = !data.last; + this.currentPage++; + } + } catch (error) { + console.error('댓글 로딩 실패:', error); + } finally { + this.isLoadingComments = false; + } + } + + renderComments(comments, isInitial = true) { + if (isInitial) { + this.commentsList.innerHTML = ''; + } + + if (isInitial && comments.length === 0) { + const emptyMessage = document.createElement('div'); + emptyMessage.className = 'comments-empty'; + emptyMessage.textContent = '아직 댓글이 없습니다. 첫 댓글을 작성해보세요!'; + this.commentsList.appendChild(emptyMessage); + return; + } + + comments.forEach(comment => { + const commentItem = document.createElement('div'); + commentItem.className = 'comment-item'; + commentItem.dataset.commentId = comment.id; + + commentItem.innerHTML = ` +
+ ${comment.author_name} + ${comment.is_owned_by_current_user ? ` +
+ 수정 + · + 삭제 +
+ ` : ''} +
+
+
+

${comment.content}

+ ${this.formatDate(comment.created_at)} + + +
+
+ `; + + this.commentsList.appendChild(commentItem); + }); + + // 댓글 수정/삭제 이벤트 리스너 + this.commentsList.querySelectorAll('.comment-edit-link').forEach(link => { + link.addEventListener('click', (e) => this.startEditComment(e.target.dataset.id)); + }); + + this.commentsList.querySelectorAll('.comment-delete-link').forEach(link => { + link.addEventListener('click', (e) => this.deleteComment(e.target.dataset.id)); + }); + + this.commentsList.querySelectorAll('.comment-save-btn').forEach(btn => { + btn.addEventListener('click', (e) => this.saveEditComment(e.target.dataset.id)); + }); + + this.commentsList.querySelectorAll('.comment-cancel-btn').forEach(btn => { + btn.addEventListener('click', (e) => this.cancelEditComment(e.target.dataset.id)); + }); + } + + async createComment() { + const content = this.commentInput.value.trim(); + if (!content) { + alert('댓글 내용을 입력해주세요.'); + return; + } + + try { + const response = await authFetch(`${HOST}/api/arts/${this.artId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ content }) + }); + + if (response.ok) { + alert('댓글이 작성되었습니다.'); + this.commentInput.value = ''; + this.commentInput.blur(); // 키보드 내리기 + this.currentPage = 0; + this.hasMoreComments = true; + await this.loadComments(true); + } else { + throw new Error('댓글 작성 실패'); + } + } catch (error) { + console.error('댓글 작성 실패:', error); + alert('댓글 작성에 실패했습니다.'); + } + } + + startEditComment(commentId) { + const commentItem = this.commentsList.querySelector(`[data-comment-id="${commentId}"]`); + const contentElement = commentItem.querySelector('.comment-content'); + const editInput = commentItem.querySelector('.comment-edit-input'); + const editActions = commentItem.querySelector('.comment-edit-actions'); + + contentElement.style.display = 'none'; + editInput.style.display = 'block'; + editActions.style.display = 'flex'; + editInput.focus(); + } + + cancelEditComment(commentId) { + const commentItem = this.commentsList.querySelector(`[data-comment-id="${commentId}"]`); + const contentElement = commentItem.querySelector('.comment-content'); + const editInput = commentItem.querySelector('.comment-edit-input'); + const editActions = commentItem.querySelector('.comment-edit-actions'); + + contentElement.style.display = 'block'; + editInput.style.display = 'none'; + editActions.style.display = 'none'; + editInput.value = contentElement.textContent; + } + + async saveEditComment(commentId) { + const commentItem = this.commentsList.querySelector(`[data-comment-id="${commentId}"]`); + const editInput = commentItem.querySelector('.comment-edit-input'); + const newContent = editInput.value.trim(); + + if (!newContent) { + alert('댓글 내용을 입력해주세요.'); + return; + } + + try { + const response = await authFetch(`${HOST}/api/arts/comments/${commentId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: newContent }) + }); + + if (response.ok) { + editInput.blur(); + this.currentPage = 0; + this.hasMoreComments = true; + await this.loadComments(true); + } else { + throw new Error('댓글 수정 실패'); + } + } catch (error) { + console.error('댓글 수정 실패:', error); + alert('댓글 수정에 실패했습니다.'); + } + } + + async deleteComment(commentId) { + if (!confirm('정말 이 댓글을 삭제하시겠습니까?')) return; + + try { + const response = await authFetch(`${HOST}/api/arts/comments/${commentId}`, { + method: 'DELETE' + }); + + if (response.ok) { + this.currentPage = 0; + this.hasMoreComments = true; + await this.loadComments(true); + } else { + throw new Error('댓글 삭제 실패'); + } + } catch (error) { + console.error('댓글 삭제 실패:', error); + alert('댓글 삭제에 실패했습니다.'); + } + } +} + +// 페이지 로드 시 상세 페이지 초기화 +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..1d25b5b --- /dev/null +++ b/src/main/resources/static/js/art/gallery.js @@ -0,0 +1,484 @@ +import * as util from "../common.js"; + +const PIXEL_THEME = { + gridSize: 30, + cellSize: 24, + gap: 4, + corner: 3, + previewScale: 12, + colors: { + background: "#251b3c", + base: "#6633cc", + hover: "#7c4dff", + active: "#cbb5ff", + activeHover: "#e4daff", + } +}; + +class Gallery { + constructor() { + this.currentPage = 0; + this.size = 20; + this.hasMore = true; + this.isLoading = false; + this.galleryContainer = document.getElementById("gallery-grid"); + + // 검색/필터 상태 + this.searchKeyword = ""; + this.sortBy = "LATEST"; + this.sortDirection = "DESC"; + this.filterType = null; // 'ONLY_MINE', 'POPULAR', 'HOT' + + this.init(); + } + + init() { + this.setupControls(); + this.loadArts(); + this.setupInfiniteScroll(); + } + + setupControls() { + const searchInput = document.getElementById("search-input"); + const searchBtn = document.getElementById("search-btn"); + const sortSelectType = document.getElementById("sort-select-type"); + const sortTrigger = document.getElementById("sort-trigger"); + const sortValue = document.getElementById("sort-value"); + const sortOptions = document.getElementById("sort-options"); + const directionToggle = document.getElementById("direction-toggle"); + const filterButtons = document.querySelectorAll(".glass-filter-btn"); + + // UI 상태 업데이트 헬퍼 함수 + const updateActiveStates = () => { + if (this.filterType === "POPULAR" || this.filterType === "HOT") { + // 필터가 활성화됨 - 정렬 비활성화 + sortTrigger.classList.remove("active"); + directionToggle.classList.remove("active"); + } else { + // 정렬이 활성화됨 - 정렬 활성화 + sortTrigger.classList.add("active"); + directionToggle.classList.add("active"); + } + }; + + // 검색 버튼 클릭 + const performSearch = () => { + this.searchKeyword = searchInput.value.trim(); + this.resetAndReload(); + }; + + searchBtn.addEventListener("click", performSearch); + + // 엔터키로도 검색 + searchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + performSearch(); + } + }); + + // 정렬 타입 셀렉트 토글 + sortTrigger.addEventListener("click", (e) => { + e.stopPropagation(); + const wasActive = sortOptions.classList.contains("active"); + sortOptions.classList.toggle("active"); + if (!wasActive) { + sortTrigger.classList.add("dropdown-open"); + } else { + sortTrigger.classList.remove("dropdown-open"); + } + }); + + // 정렬 타입 옵션 선택 + sortOptions.querySelectorAll("li").forEach((option) => { + option.addEventListener("click", (e) => { + e.stopPropagation(); + const value = option.dataset.value; + const text = option.textContent; + + // 선택된 옵션 업데이트 + sortOptions.querySelectorAll("li").forEach((li) => li.classList.remove("selected")); + option.classList.add("selected"); + sortValue.textContent = text; + + // POPULAR/HOT 필터만 해제 (내작품만은 유지) + if (this.filterType === "POPULAR" || this.filterType === "HOT") { + this.filterType = null; + filterButtons.forEach((b) => b.classList.remove("active")); + } + + // 상태 업데이트 및 재로드 + this.sortBy = value; + updateActiveStates(); + this.resetAndReload(); + + // 드롭다운 닫기 + sortTrigger.classList.remove("dropdown-open"); + sortOptions.classList.remove("active"); + }); + }); + + // 외부 클릭시 드롭다운 닫기 + document.addEventListener("click", (e) => { + if (!sortSelectType.contains(e.target)) { + sortTrigger.classList.remove("dropdown-open"); + sortOptions.classList.remove("active"); + } + }); + + // 정렬 방향 토글 + directionToggle.addEventListener("click", () => { + // POPULAR/HOT 필터만 해제 (내작품만은 유지) + if (this.filterType === "POPULAR" || this.filterType === "HOT") { + this.filterType = null; + filterButtons.forEach((b) => b.classList.remove("active")); + } + + 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 = ` + + + + `; + } + updateActiveStates(); + this.resetAndReload(); + }); + + // 필터 버튼 클릭 + filterButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const filter = btn.dataset.filter; + + // 이미 활성화된 버튼을 다시 클릭하면 필터 해제 + if (btn.classList.contains("active")) { + btn.classList.remove("active"); + this.filterType = null; + } else { + // 모든 버튼에서 active 제거 후 클릭한 버튼만 active + filterButtons.forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + this.filterType = filter; + } + + updateActiveStates(); + this.resetAndReload(); + }); + }); + + // 초기 상태 설정 + updateActiveStates(); + } + + resetAndReload() { + this.currentPage = 0; + this.hasMore = true; + this.galleryContainer.innerHTML = ""; + this.loadArts(); + } + + setupInfiniteScroll() { + const galleryContent = document.getElementById("gallery-content"); + galleryContent.addEventListener("scroll", () => { + if (galleryContent.scrollHeight - galleryContent.scrollTop <= galleryContent.clientHeight + 500) { + this.loadArts(); + } + }); + } + + 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.filterType) { + params.append("filterType", this.filterType); + } + + return params.toString(); + } + + async loadArts() { + if (!this.hasMore || this.isLoading) { + return; + } + + this.isLoading = true; + + try { + 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) { + this.renderArts(data.content); + this.currentPage++; + this.hasMore = !data.last; + } else { + this.hasMore = false; + // 첫 페이지에서 결과가 없으면 빈 결과 메시지 표시 + if (this.currentPage === 0) { + this.showEmptyMessage(); + } + } + } catch (error) { + console.error("failed to load arts", error); + alert("작품을 불러오는데 실패했습니다."); + } finally { + this.isLoading = false; + } + } + + 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.style.cursor = "pointer"; + 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"; + + 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'; + const strokeColor = '#1C1B20FF'; + + artInfo.innerHTML = ` +
+
+ + + + + +
+
+ + + + ${this.formatCount(art.comment_count || 0)} +
+
+ + + + ${this.formatCount(art.view_count || 0)} +
+
+
+ ${art.author_name} + ${art.title} +
+ + `; + + // 좋아요 버튼 이벤트 리스너 + 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; + + 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); + + // 성능 최적화: roundRect 사용 (그림자 제거) + ctx.fillStyle = PIXEL_THEME.colors.base; + ctx.beginPath(); + for (let index = 0; index < pixels.length; index++) { + if (pixels[index]) continue; // 비활성만 먼저 + + const col = index % width; + const row = Math.floor(index / width); + const x = col * pixelSize + gap / 2; + const y = row * pixelSize + gap / 2; + ctx.roundRect(x, y, innerSize, innerSize, radius); + } + ctx.fill(); + + // 활성 픽셀 + ctx.fillStyle = PIXEL_THEME.colors.active; + ctx.beginPath(); + for (let index = 0; index < pixels.length; index++) { + if (!pixels[index]) continue; // 활성만 + + const col = index % width; + const row = Math.floor(index / width); + const x = col * pixelSize + gap / 2; + const y = row * pixelSize + gap / 2; + ctx.roundRect(x, y, innerSize, innerSize, radius); + } + ctx.fill(); + } + + normalizePixelData(pixelData, width, height) { + if (!pixelData || typeof pixelData !== "string") return []; + + const total = width * height; + const cleaned = pixelData.replace(/[^01]/g, ""); + const pixels = [...cleaned].map((digit) => digit === "1"); + + return this.fillToSize(pixels, total); + } + + // 픽셀 데이터가 canvas 사이즈에 맞지 않은 예외 케이스 처리 + fillToSize(pixels, total) { + if (pixels.length >= total) { + return pixels.slice(0, total); + } + const padding = Array(total - pixels.length).fill(false); + return pixels.concat(padding); + } + + 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", + });; + } + + 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(); + } + + showEmptyMessage() { + const emptyMessage = document.createElement("div"); + emptyMessage.className = "gallery-empty"; + emptyMessage.textContent = "검색 결과가 없습니다."; + this.galleryContainer.appendChild(emptyMessage); + } +} + +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..baec35b 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() { @@ -104,17 +106,28 @@ function markCurrentPage() { let currentPath = window.location.pathname; currentPath = currentPath === "/" ? "ticketing" : currentPath.substring(1); + let pageName = currentPath; + if (currentPath.startsWith("art")) { + pageName = "art"; + } + // 모바일 버튼 처리 - const mobileButton = mobilePageInfos[currentPath]; + const mobileButton = mobilePageInfos[pageName]; Object.values(mobilePageInfos).forEach(btn => { - btn.style.backgroundColor = "white"; + if (btn) { + btn.style.backgroundColor = "white"; + } }); - mobileButton.style.backgroundColor = "darkslateblue"; - mobileButton.style.color = "white"; + if (mobileButton) { + mobileButton.style.backgroundColor = "darkslateblue"; + mobileButton.style.color = "white"; + } // 데스크탑 버튼 처리 - const desktopButton = desktopPageInfos[currentPath]; - desktopButton.style.color = "darkslateblue"; + const desktopButton = desktopPageInfos[pageName]; + if (desktopButton) { + desktopButton.style.color = "darkslateblue"; + } } await util.getOrCreateToken(); diff --git a/src/main/resources/templates/art/create.html b/src/main/resources/templates/art/create.html new file mode 100644 index 0000000..e7e4459 --- /dev/null +++ b/src/main/resources/templates/art/create.html @@ -0,0 +1,47 @@ + + + + + + 작품 만들기 - 프랙티켓 + + + + + + +
+
+
+
+
+

포도아트 만들기

+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + \ 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..c72d84a --- /dev/null +++ b/src/main/resources/templates/art/detail.html @@ -0,0 +1,82 @@ + + + + + + 작품 상세 - 프랙티켓 + + + + + + +
+
+
+
+
+
+
+
+
+ 작성자 + + 작품 제목 +
+
+ 수정 + + 삭제 +
+
+
+
+
+ + + + 0 +
+
+ + + + 0 +
+
+ + + + 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..5cc9a2f --- /dev/null +++ b/src/main/resources/templates/art/gallery.html @@ -0,0 +1,72 @@ + + + + + + + + 포도아트 - 프랙티켓 + + + + + + +
+
+
+ +
+
+ + + + + + + \ 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..40c6b55 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -8,6 +8,7 @@ 예매연습 순위표 보안문자 + 포도아트 블로그