diff --git a/v2/backend/greetingCard/.gitignore b/v2/backend/greetingCard/.gitignore
index 3d5431b..c8d5658 100644
--- a/v2/backend/greetingCard/.gitignore
+++ b/v2/backend/greetingCard/.gitignore
@@ -36,4 +36,5 @@ out/
### VS Code ###
.vscode/
-.env
\ No newline at end of file
+.env
+v2/backend/greetingCard/src/main/resources/application.properties
\ No newline at end of file
diff --git a/v2/backend/greetingCard/Dockerfile b/v2/backend/greetingCard/Dockerfile
index 5d26185..573054d 100644
--- a/v2/backend/greetingCard/Dockerfile
+++ b/v2/backend/greetingCard/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Build the application using Gradle Wrapper
-FROM openjdk:21-jdk-slim AS build
+FROM openjdk:17-jdk-slim AS build
# Install dependencies for building
RUN apt-get update && apt-get install -y wget unzip && rm -rf /var/lib/apt/lists/*
diff --git a/v2/backend/greetingCard/build.gradle b/v2/backend/greetingCard/build.gradle
index 2276fee..d1d47cd 100644
--- a/v2/backend/greetingCard/build.gradle
+++ b/v2/backend/greetingCard/build.gradle
@@ -10,7 +10,7 @@ sourceCompatibility = '17'
java {
toolchain {
- languageVersion = JavaLanguageVersion.of(21)
+ languageVersion = JavaLanguageVersion.of(17)
}
}
@@ -39,6 +39,8 @@ dependencies {
implementation 'com.amazonaws:aws-java-sdk-s3:1.11.1000'
implementation 'org.mariadb.jdbc:mariadb-java-client'
+ // 테스트용 h2 database
+ runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
diff --git a/v2/backend/greetingCard/docker-compose.yml b/v2/backend/greetingCard/docker-compose.yml
index 3232fc1..bf582dd 100644
--- a/v2/backend/greetingCard/docker-compose.yml
+++ b/v2/backend/greetingCard/docker-compose.yml
@@ -7,6 +7,8 @@ services:
dockerfile: Dockerfile
ports:
- "8080:8080"
+ env_file:
+ - .env
environment:
SPRING_DATASOURCE_URL: ${DB_URL}
SPRING_DATASOURCE_USERNAME: ${DB_USERNAME}
@@ -22,6 +24,8 @@ services:
db:
image: mariadb:10.5
restart: always
+ env_file:
+ - .env
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: greetingdb
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/configuration/AWSConfig.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/configuration/AWSConfig.java
index 7d6d4a4..abf72ee 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/configuration/AWSConfig.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/configuration/AWSConfig.java
@@ -12,7 +12,7 @@
/**
* 해당 파일은 수정하지 않아도 됩니다. 따봉 6기가 config를 작성해뒀다구!
*
- * 동작하지 않는다면 에러 로그를 확인하고 .env 파일을 제대로 넣었는지 확인해보세요!!
+ * 동작하지 않는다면 에러 로그를 확인하고 application.properties 파일을 제대로 넣었는지 확인해보세요!!
*/
@Configuration
public class AWSConfig {
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/GroupSearchDto.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/GroupSearchDto.java
new file mode 100644
index 0000000..c7bee5b
--- /dev/null
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/GroupSearchDto.java
@@ -0,0 +1,12 @@
+package com.cabi.greetingCard.dto;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class GroupSearchDto {
+
+ List GroupNames;
+}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/MessageResponsePaginationDto.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/MessageResponsePaginationDto.java
index 3699993..3a139f4 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/MessageResponsePaginationDto.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/dto/MessageResponsePaginationDto.java
@@ -9,5 +9,6 @@
public class MessageResponsePaginationDto {
private List messages;
- private Long totalLength;
+ private int totalLength;
+ private int currentPage;
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionController.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionController.java
index 08100b1..1cd7d97 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionController.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionController.java
@@ -15,7 +15,7 @@ public class ExceptionController {
@ExceptionHandler(GreetingException.class)
public ResponseEntity> serviceExceptionHandler(GreetingException e) {
return ResponseEntity
- .status(e.status.getErrorCode())
+ .status(e.status.getHttpCode())
.body(e.status);
}
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionStatus.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionStatus.java
index 5237e4a..14a118c 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionStatus.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/exception/ExceptionStatus.java
@@ -11,23 +11,28 @@
@Getter
public enum ExceptionStatus {
- NOT_FOUND_MESSAGE(HttpStatus.NOT_FOUND, "존재하지 않는 메세지입니다"),
- UNAUTHORIZED_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"),
- DUPLICATED_NAME(HttpStatus.UNAUTHORIZED, "중복된 아이디입니다."),
- INVALID_NAME(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 아이디입니다."),
- INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 비밀번호입니다."),
- NOT_FOUND_USER(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니디."),
- INVALID_FORMAT_MESSAGE(HttpStatus.BAD_REQUEST, "잘못된 형식의 메세지입니다!"),
- ;
+ LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "로그인에 실패했습니다.", "001"),
+ DUPLICATED_NAME(HttpStatus.UNAUTHORIZED, "중복된 아이디입니다.", "002"),
+ INVALID_NAME(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 아이디입니다.", "003"),
+ INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 비밀번호입니다.", "004"),
+ INVALID_GROUP_ACCESS(HttpStatus.BAD_REQUEST, "잘못된 접근입니다.", "005"),
+ INVALID_FORMAT_MESSAGE(HttpStatus.BAD_REQUEST, "잘못된 형식의 메세지입니다!", "007"),
+ NOT_FOUND_USER(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니디.", "008"),
+ INVALID_QUERYSTRING(HttpStatus.BAD_REQUEST, "잘못된 쿼리스트링입니다.", "009"),
+ UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "권한이 없습니다.", "010"),
+ NOT_FOUND_MESSAGE(HttpStatus.NOT_FOUND, "존재하지 않는 메세지입니다", "011"),
+ SENDER_EQUAL_RECEIVER(HttpStatus.BAD_REQUEST, "보내는 사람과 받는 사람이 같습니다.", "013");
- private final int errorCode;
+ private final int httpCode;
private final String message;
private final String error;
+ private final String errorCode;
- ExceptionStatus(HttpStatus status, String message) {
- this.errorCode = status.value();
+ ExceptionStatus(HttpStatus status, String message, String errorCode) {
+ this.httpCode = status.value();
this.message = message;
this.error = status.getReasonPhrase();
+ this.errorCode = errorCode;
}
public GreetingException asGreetingException() {
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/controller/MessageController.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/controller/MessageController.java
index d163c5e..4c0599b 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/controller/MessageController.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/controller/MessageController.java
@@ -8,19 +8,20 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
-import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
-@RequestMapping("/주요 리소스가 누구지..")
+@RequestMapping("/messages")
@Slf4j
public class MessageController {
@@ -36,7 +37,7 @@ public class MessageController {
* @throws IOException
*/
@PostMapping("")
- public void sendMessage(@CookieValue(name = "userName") String userName,
+ public void sendMessage(@CookieValue(name = "userName", required = false) String userName,
@ModelAttribute MessageRequestDto messageData) throws IOException {
messageService.sendMessage(userName, messageData);
}
@@ -46,46 +47,33 @@ public void sendMessage(@CookieValue(name = "userName") String userName,
*
* @param userName
* @param pageable
+ * @param category
* @return
*/
@GetMapping("")
- public MessageResponsePaginationDto getAllMessages(
+ public ResponseEntity getAllMessages(
@CookieValue(name = "userName") String userName,
- Pageable pageable) {
- return messageService.getEveryoneMessage(userName, pageable);
+ Pageable pageable,
+ int category) {
+ MessageResponsePaginationDto messages = messageService.getMessages(userName, pageable,
+ category);
+
+ return ResponseEntity.ok()
+ .body(messages);
}
/**
- * 모두에게 덕담 메세지 보내기
+ * 메세지 내용을 수정합니다.
*
* @param userName
- * @param message
- * @throws IOException
+ * @param messageId
+ * @param requestDto
*/
- @PostMapping("/test1")
- public void postAllUsers(@CookieValue(name = "userName") String userName,
- @ModelAttribute MessageRequestDto message) throws IOException {
- messageService.sendMessage(userName, message);
- }
-
- @GetMapping("/test2")
- public MessageResponsePaginationDto getReceivedMessages(
- @CookieValue(name = "userName") String userName,
- Pageable pageable) {
- return messageService.getReceivedMessages(userName, pageable);
- }
-
- @GetMapping("/test3")
- public MessageResponsePaginationDto getSentMessages(
- @CookieValue(name = "userName") String userName,
- Pageable pageable) {
- return messageService.getSentMessages(userName, pageable);
- }
-
- @PatchMapping("/{test4}")
- public void updateMessageContext(@CookieValue(name = "userName") String userName,
- @PathVariable(name = "test?") Long messageId,
+ @PutMapping("/{messageId}")
+ public ResponseEntity> updateMessageContext(@CookieValue(name = "userName") String userName,
+ @PathVariable(name = "messageId") Long messageId,
@RequestBody RequestDto requestDto) {
messageService.updateMessageContext(userName, messageId, requestDto.getContext());
+ return ResponseEntity.ok().build();
}
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/Message.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/Message.java
index bd29422..40063ea 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/Message.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/Message.java
@@ -8,8 +8,6 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.List;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -66,5 +64,6 @@ private boolean isValid() {
}
public void updateContext(String context) {
+ this.context = context;
}
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/MessageCategory.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/MessageCategory.java
new file mode 100644
index 0000000..9351508
--- /dev/null
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/domain/MessageCategory.java
@@ -0,0 +1,16 @@
+package com.cabi.greetingCard.message.domain;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum MessageCategory {
+
+ TO_EVERYONE("@everyone", 0),
+ TO_ME("to_me", 1),
+ FROM_ME("from_me", 2);
+
+ private final String name;
+ private final int number;
+}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/repository/MessageRepository.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/repository/MessageRepository.java
index f416080..e3b9254 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/repository/MessageRepository.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/repository/MessageRepository.java
@@ -1,10 +1,15 @@
package com.cabi.greetingCard.message.repository;
import com.cabi.greetingCard.message.domain.Message;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MessageRepository extends JpaRepository {
+ Page findAllByReceiverName(String receiverName, Pageable pageable);
+
+ Page findAllBySenderName(String name, Pageable pageable);
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/service/MessageService.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/service/MessageService.java
index 554938f..608fab4 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/service/MessageService.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/message/service/MessageService.java
@@ -6,11 +6,14 @@
import com.cabi.greetingCard.dto.MessageResponseDto;
import com.cabi.greetingCard.dto.MessageResponsePaginationDto;
import com.cabi.greetingCard.exception.ExceptionStatus;
+import com.cabi.greetingCard.mapper.MessageMapper;
import com.cabi.greetingCard.message.domain.Message;
+import com.cabi.greetingCard.message.domain.MessageCategory;
import com.cabi.greetingCard.message.repository.MessageRepository;
+import com.cabi.greetingCard.user.repository.UserRepository;
+import com.cabi.greetingCard.user.service.UserService;
import java.io.IOException;
import java.time.LocalDateTime;
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -18,7 +21,9 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -28,10 +33,13 @@
@Slf4j
public class MessageService {
- private static final String EVERYONE = "@everyone";
+ static final int MESSAGE_LENGTH_LIMIT = 42;
private final MessageRepository messageRepository;
private final AmazonS3 s3Client;
+ private final UserRepository userRepository;
+ private final MessageMapper messageMapper;
+ private final UserService userService;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
@@ -44,15 +52,21 @@ public class MessageService {
*/
@Transactional
public void sendMessage(String userName, MessageRequestDto messageData) throws IOException {
+ userService.checkAuth(userName);
+
String imageUrl = saveImage(messageData.getImage());
+ verifyExistUser(messageData);
+ verifyValidMessageFormat(messageData.getContext());
+ verifySenderNotEqualReceiver(userName, messageData);
+
Message message =
Message.of(userName, messageData.getReceiverName(), messageData.getContext(),
imageUrl, LocalDateTime.now());
+
messageRepository.save(message);
}
-
/**
* 수정할 부분 없읍니다!
*
@@ -68,9 +82,11 @@ public String saveImage(Optional imageFile) throws IOException {
}
MultipartFile image = imageFile.get();
String originalFilename = image.getOriginalFilename();
- String extension = originalFilename.contains(".") ? originalFilename.substring(originalFilename.lastIndexOf(".")) : "";
+ String extension = originalFilename.contains(".") ? originalFilename.substring(
+ originalFilename.lastIndexOf(".")) : "";
verifyExtensionType(extension);
- String safeFileName = originalFilename.length() > 10 ? originalFilename.substring(0, 10) : originalFilename;
+ String safeFileName = originalFilename.length() > 10 ? originalFilename.substring(0, 10)
+ : originalFilename;
String s3FileName = UUID.randomUUID() + safeFileName + extension;
ObjectMetadata objectMetadata = new ObjectMetadata();
@@ -89,63 +105,134 @@ private void verifyExtensionType(String extension) {
}
/**
- * receiverName을 everyone으로 받은 메세지 조회
+ * 내가 보낸 메세지 수정
*
- * dto는 왜쓰는걸까용?
+ * 메세지의 발신자와 쿠키 내의 userName이 같지 않다면, 에러를 반환합니다.
*
* @param userName
- * @param page
- * @param size
- * @return
+ * @param messageId
+ * @param context
*/
- public MessageResponsePaginationDto getEveryoneMessage(String userName, Pageable pageable) {
- Page receivedMessages;
-
- List messageDtos = new ArrayList<>();
- return new MessageResponsePaginationDto(messageDtos, 0L);
+ @Transactional
+ public void updateMessageContext(String userName, Long messageId, String context) {
+ Message message = messageRepository.findById(messageId)
+ .orElseThrow(ExceptionStatus.NOT_FOUND_MESSAGE::asGreetingException);
+ verifyUserAuthorized(userName, message);
+ verifyValidMessageFormat(context);
+ message.updateContext(context);
}
/**
- * 내가 받은 메세지 조회
+ * 조건에 맞는 메세지 리스트를 반환합니다. 페이징 처리가 되며 카테고리마다 다른 결과를 반환합니다.
*
* @param userName
* @param pageable
* @return
*/
- public MessageResponsePaginationDto getReceivedMessages(String userName, Pageable pageable) {
- Page receivedMessages;
+ @Transactional
+ public MessageResponsePaginationDto getMessages(String userName, Pageable pageable,
+ int category) {
+ verifyValidPageInfo(pageable);
- List messageDtos = new ArrayList<>();
- return new MessageResponsePaginationDto(messageDtos, 0L);
+ PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(),
+ pageable.getPageSize(),
+ Sort.by("created").descending());
+
+ Page messageList = splitByCategory(userName, category, pageRequest);
+
+ List messageResponseDtoList = messageList.stream()
+ .map(message -> messageMapper.toMessageResponseDto(message,
+ userName.equals(message.getSenderName()))).toList();
+
+ return new MessageResponsePaginationDto(messageResponseDtoList,
+ messageList.getTotalPages(), pageable.getPageNumber());
}
/**
- * 내가 보낸 메세지 조회
+ * 카테고리에 맞게 데이터를 조회하는 메서드를 호출하고 리턴값을 반환합니다.
*
* @param userName
- * @param pageable
+ * @param category
+ * @param pageRequest
* @return
*/
- public MessageResponsePaginationDto getSentMessages(String userName, Pageable pageable) {
- Page sentMessages;
- List messageDtos = new ArrayList<>();
- return new MessageResponsePaginationDto(messageDtos, 0L);
+ private Page splitByCategory(String userName, int category, PageRequest pageRequest) {
+ Page messageList;
+ if (category == MessageCategory.TO_EVERYONE.getNumber()) {
+ messageList = getMessagesSendEveryone(pageRequest);
+ } else if (category == MessageCategory.TO_ME.getNumber()) {
+ messageList = getMessagesSendMe(userName, pageRequest);
+ } else if (category == MessageCategory.FROM_ME.getNumber()) {
+ messageList = getMessagesFromMe(userName, pageRequest);
+ } else {
+ throw ExceptionStatus.INVALID_QUERYSTRING.asGreetingException();
+ }
+ return messageList;
}
/**
- * 내가 보낸 메세지 수정
- *
- * 메세지의 발신자와 쿠키 내의 userName이 같지 않다면, 에러를 반환합니다.
+ * 내가 보낸 메세지들을 조회합니다.
*
* @param userName
- * @param messageId
- * @param context
+ * @param pageRequest
+ * @return
*/
- @Transactional
- public void updateMessageContext(String userName, Long messageId, String context) {
- Message message = messageRepository.findById(messageId)
- .orElseThrow(ExceptionStatus.NOT_FOUND_MESSAGE::asGreetingException);
- message.updateContext(context);
+ private Page getMessagesFromMe(String userName, PageRequest pageRequest) {
+ return messageRepository.findAllBySenderName(userName, pageRequest);
+ }
+
+ /**
+ * 모두에게 보내진 메세지들을 조회합니다.
+ *
+ * @param pageRequest
+ * @return
+ */
+ private Page getMessagesSendEveryone(PageRequest pageRequest) {
+ return messageRepository.findAllByReceiverName(MessageCategory.TO_EVERYONE.getName(),
+ pageRequest);
+ }
+
+ /**
+ * 나에게 보내진 메세지들을 조회합니다.
+ *
+ * @param userName
+ * @param pageRequest
+ * @return
+ */
+ private Page getMessagesSendMe(String userName, PageRequest pageRequest) {
+ return messageRepository.findAllByReceiverName(userName, pageRequest);
+ }
+
+ private void verifyExistUser(MessageRequestDto messageData) {
+ if (!userRepository.existsByName(messageData.getReceiverName())
+ && !userService.checkGroupExists(messageData.getReceiverName())) {
+ throw ExceptionStatus.NOT_FOUND_USER.asGreetingException();
+ }
+ }
+
+ private void verifyValidMessageFormat(String context) {
+ if (context.isEmpty()
+ || context.length() > MESSAGE_LENGTH_LIMIT) {
+ throw ExceptionStatus.INVALID_FORMAT_MESSAGE.asGreetingException();
+ }
+ }
+
+ private void verifyUserAuthorized(String userName, Message message) {
+ if (!message.getSenderName().equals(userName)) {
+ throw ExceptionStatus.UNAUTHORIZED.asGreetingException();
+ }
+ }
+
+ private void verifyValidPageInfo(Pageable pageable) {
+ if (pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0) {
+ throw ExceptionStatus.INVALID_QUERYSTRING.asGreetingException();
+ }
+ }
+
+ private void verifySenderNotEqualReceiver(String userName, MessageRequestDto messageData) {
+ if (userName.equals(messageData.getReceiverName())) {
+ throw ExceptionStatus.SENDER_EQUAL_RECEIVER.asGreetingException();
+ }
}
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/controller/UserController.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/controller/UserController.java
index 0b113d2..7b1e422 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/controller/UserController.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/controller/UserController.java
@@ -1,9 +1,13 @@
package com.cabi.greetingCard.user.controller;
+import com.cabi.greetingCard.dto.GroupSearchDto;
import com.cabi.greetingCard.dto.UserInfoDto;
import com.cabi.greetingCard.dto.UserSearchDto;
import com.cabi.greetingCard.user.service.UserService;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -21,7 +25,7 @@
*/
@RestController
@RequiredArgsConstructor
-@RequestMapping("/주체가누구지..")
+@RequestMapping("/users")
public class UserController {
private final UserService userService;
@@ -29,15 +33,41 @@ public class UserController {
/**
* PostMapping, GetMapping이 머지??
*/
- @PostMapping("/test1")
+ @PostMapping("/register")
public void registerUser(@RequestBody UserInfoDto userInfoDto) {
userService.registerUser(userInfoDto.getName(), userInfoDto.getPassword());
}
- @GetMapping("/test2")
- public UserSearchDto searchUser(@RequestParam(name = "name") String name,
+ /**
+ * input값으로 시작하는 유저들의 목록을 반환합니다.
+ *
+ * @param input
+ * @param userName
+ * @return
+ */
+ @GetMapping("/search/name")
+ public ResponseEntity> searchUser(@RequestParam(name = "input") String input,
@CookieValue(name = "userName") String userName) {
- return userService.searchUserByName(name);
+
+ UserSearchDto users = userService.searchUserByName(input, userName);
+
+ return ResponseEntity.ok()
+ .body(users);
+ }
+
+ /**
+ * input값으로 시작하는 그룹들의 목록을 반환합니다.
+ *
+ * @param input
+ * @return
+ */
+ @GetMapping("/search/group")
+ public ResponseEntity> searchGroup(@RequestParam(name = "input") String input) {
+
+ GroupSearchDto groups = userService.searchGroupByName(input);
+
+ return ResponseEntity.ok()
+ .body(groups);
}
/**
@@ -52,8 +82,28 @@ public UserSearchDto searchUser(@RequestParam(name = "name") String name,
* @param userInfoDto
* @return
*/
- @PostMapping("/test3")
- public void login(@RequestBody UserInfoDto userInfoDto) {
- userService.login(userInfoDto.getName(), userInfoDto.getPassword());
+ @PostMapping("/login")
+ public ResponseEntity> login(@RequestBody UserInfoDto userInfoDto) {
+ ResponseCookie cookie = userService.login(userInfoDto.getName(), userInfoDto.getPassword());
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.SET_COOKIE, cookie.toString());
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .build();
}
+
+ /**
+ * 요청 헤더에 있는 쿠키를 확인하고 유효한 유저인지 확인합니다.
+ */
+ @GetMapping("/auth")
+ public ResponseEntity> checkAuth(
+ @CookieValue(value = "userName", required = false) String name) {
+ userService.checkAuth(name);
+
+ return ResponseEntity.ok().build();
+ }
+
+
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/GroupNames.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/GroupNames.java
new file mode 100644
index 0000000..e6e0b43
--- /dev/null
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/GroupNames.java
@@ -0,0 +1,6 @@
+package com.cabi.greetingCard.user.domain;
+
+public class GroupNames {
+
+ public static final String GROUP_EVERYONE = "@everyone";
+}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/User.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/User.java
index 66de626..52ca297 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/User.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/domain/User.java
@@ -5,6 +5,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -12,6 +13,7 @@
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
+@Table(name = "USERS")
public class User {
@Id
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/repository/UserRepository.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/repository/UserRepository.java
index 091b70c..e53d645 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/repository/UserRepository.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/repository/UserRepository.java
@@ -1,6 +1,8 @@
package com.cabi.greetingCard.user.repository;
import com.cabi.greetingCard.user.domain.User;
+import java.util.List;
+import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -10,4 +12,9 @@
@Repository
public interface UserRepository extends JpaRepository {
+ boolean existsByName(String name);
+
+ Optional findByName(String name);
+
+ List findAllByNameStartingWithOrderByName(String input);
}
diff --git a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/service/UserService.java b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/service/UserService.java
index a292279..ac95ba1 100644
--- a/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/service/UserService.java
+++ b/v2/backend/greetingCard/src/main/java/com/cabi/greetingCard/user/service/UserService.java
@@ -1,21 +1,38 @@
package com.cabi.greetingCard.user.service;
+import static com.cabi.greetingCard.user.domain.GroupNames.GROUP_EVERYONE;
+
+import com.cabi.greetingCard.dto.GroupSearchDto;
import com.cabi.greetingCard.dto.UserSearchDto;
+import com.cabi.greetingCard.exception.ExceptionStatus;
import com.cabi.greetingCard.user.domain.User;
import com.cabi.greetingCard.user.repository.UserRepository;
-import jakarta.servlet.http.Cookie;
-import java.util.ArrayList;
+import jakarta.annotation.PostConstruct;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
/**
* 이 어노테이션은 또 멀까? 컴포넌트 서치가 머더라? Autowired는 또 머지..
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
+ private static final int USER_NAME_LENGTH_LIMIT = 10;
+ private static final int COOKIE_MAX_AGE = 24 * 60 * 60;
private final UserRepository userRepository;
+ private Set groupNames;
+
+ @PostConstruct
+ public void init() {
+ groupNames = new HashSet<>(List.of(GROUP_EVERYONE));
+ }
/**
* 새로운 유저를 등록합니다
@@ -26,36 +43,98 @@ public class UserService {
*/
public void registerUser(String name, String password) {
verifyDuplicatedName(name);
+ verifyNameLength(name);
+ verifyNameIsNumericOrAlphabet(name); // 하나의 메서드로 따로 빼는게 나을까? 코드만 복잡해지는게 아닌지?
User user = new User(name, password);
userRepository.save(user);
}
/**
- * 중복된 name 검증 기능
+ * 로그인을 시도합니다
+ *
+ * 진짜 있는 유저일까? 비밀번호는 맞게 입력했을까?, 성공하면 쿠키를 주자!
+ */
+ public ResponseCookie login(String name, String password) {
+ User loginUser = userRepository.findByName(name)
+ .orElseThrow(ExceptionStatus.LOGIN_FAIL::asGreetingException);
+
+ if (!loginUser.getPassword().equals(password)) {
+ throw ExceptionStatus.LOGIN_FAIL.asGreetingException();
+ }
+
+ return ResponseCookie.from("userName", name)
+ .maxAge(COOKIE_MAX_AGE) // 유효 기간 1일
+ .path("/") // 경로 설정
+ .build();
+ }
+
+ /**
+ * input값으로 시작하는 유저들의 이름 목록을 반환합니다. 로그인한 유저의 이름은 제외됩니다.
*
- * @param name
+ * @param input
+ * @param userName
+ * @return
*/
- public void verifyDuplicatedName(String name) {
+ public UserSearchDto searchUserByName(String input, String userName) {
+ List nameList = userRepository.findAllByNameStartingWithOrderByName(input).stream()
+ .map(User::getName)
+ .filter(name -> !name.equals(userName))
+ .toList();
+
+ return new UserSearchDto(nameList);
}
/**
- * 로그인을 시도합니다
- *
- * 진짜 있는 유저일까? 비밀번호는 맞게 입력했을까?, 성공하면 쿠키를 주자!
+ * input값으로 시작하는 그룹들의 목록을 반환합니다.
+ *
+ * @param input
+ * @return
*/
- public void login(String name, String password) {
- User user;
+ public GroupSearchDto searchGroupByName(String input) {
+ List list = groupNames.stream().filter(group -> group.startsWith("@" + input))
+ .toList();
- Cookie cookie = new Cookie("쿠키는 어떻게, 왜 쓰는걸까요?", "파라미터는 뭘 줘야하지?");
+ return new GroupSearchDto(list);
}
/**
- * 파라미터를 포함하고 있는 user정보들 중 name만을 List 형식으로 반환
+ * 로그인 권한이 필요한 페이지마다 현재 로그인한 유저가 유효한지 확인합니다.
*
* @param name
- * @return
*/
- public UserSearchDto searchUserByName(String name) {
- return new UserSearchDto(new ArrayList<>());
+ public void checkAuth(String name) {
+ // 잘못된 쿠키인 경우
+ if (name == null) {
+ throw ExceptionStatus.UNAUTHORIZED.asGreetingException();
+ }
+
+ // 쿠키에 유저이름이 있지만 데이터베이스와 일치하지 않는 경우
+ if (!userRepository.existsByName(name)) {
+ System.out.println("다람쥐");
+ throw ExceptionStatus.NOT_FOUND_USER.asGreetingException();
+ }
+ }
+
+ public boolean checkGroupExists(String groupName) {
+ return groupNames.contains(groupName);
}
+
+ private void verifyNameIsNumericOrAlphabet(String name) {
+ if (name == null || !name.matches("^[a-zA-Z0-9]+$")) {
+ throw ExceptionStatus.INVALID_NAME.asGreetingException();
+ }
+ }
+
+ private void verifyNameLength(String name) {
+ if (name.length() > USER_NAME_LENGTH_LIMIT) {
+ throw ExceptionStatus.INVALID_NAME.asGreetingException();
+ }
+ }
+
+ public void verifyDuplicatedName(String name) {
+ if (userRepository.existsByName(name)) {
+ throw ExceptionStatus.DUPLICATED_NAME.asGreetingException();
+ }
+ }
+
}
diff --git a/v2/backend/greetingCard/src/main/resources/application.yml b/v2/backend/greetingCard/src/main/resources/application.yml
index 7cd1a3c..c6aa270 100644
--- a/v2/backend/greetingCard/src/main/resources/application.yml
+++ b/v2/backend/greetingCard/src/main/resources/application.yml
@@ -12,7 +12,23 @@ spring:
max-file-size: 30MB
max-request-size: 30MB
config:
- import: optional:file:.env[.properties]
+ import: application.properties
+
+#spring:
+# datasource:
+# driver-class-name: org.h2.Driver
+# url: jdbc:h2:tcp://localhost/~/onboarding
+# username: root
+# password: 1234
+#
+# jpa:
+# hibernate:
+# ddl-auto: update
+# properties:
+# hibernate:
+# show_sql: true #콘솔에 로그가 나옴
+# format_sql: true #이쁘게 해줌
+
cloud:
aws:
@@ -31,3 +47,7 @@ logging:
level:
root: INFO
org.springframework: INFO
+
+server:
+ servlet:
+ context-path: /api
diff --git a/v2/frontend/.prettierrc b/v2/frontend/.prettierrc
new file mode 100644
index 0000000..c81080a
--- /dev/null
+++ b/v2/frontend/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "singleQuote": false,
+ "semi": true,
+ "useTabs": false,
+ "tabWidth": 2,
+ "printWidth": 80,
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "importOrderSeparation": false,
+ "importOrderSortSpecifiers": true
+}
diff --git a/v2/frontend/src/App.tsx b/v2/frontend/src/App.tsx
index 84923e8..67808b3 100644
--- a/v2/frontend/src/App.tsx
+++ b/v2/frontend/src/App.tsx
@@ -1,14 +1,11 @@
-import { BrowserRouter, Route, Routes } from "react-router-dom";
-import MainPage from "./MainPage";
+import { RouterProvider, createBrowserRouter } from "react-router-dom";
+import { routes } from "./routes";
+import "./index.css";
+
+const router = createBrowserRouter(routes);
function App() {
- return (
-
-
- } />
-
-
- );
+ return ;
}
export default App;
diff --git a/v2/frontend/src/MainPage.tsx b/v2/frontend/src/MainPage.tsx
deleted file mode 100644
index 78ff93e..0000000
--- a/v2/frontend/src/MainPage.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const MainPage = () => {
- return main page
;
-};
-
-export default MainPage;
diff --git a/v2/frontend/src/api/axios.custom.ts b/v2/frontend/src/api/axios.custom.ts
new file mode 100644
index 0000000..9adec74
--- /dev/null
+++ b/v2/frontend/src/api/axios.custom.ts
@@ -0,0 +1,27 @@
+import axios from "axios";
+
+const axiosInstance = axios.create({
+ baseURL: "http://localhost:8080/api", // 기본 URL
+ headers: {
+ "Content-Type": "application/json",
+ },
+ withCredentials: true,
+});
+
+export const service = {
+ post: async (url: string, data: object, config?: object) => {
+ return axiosInstance.post(url, data, config);
+ },
+
+ get: async (url: string, config?: { params?: object }) => {
+ return axiosInstance.get(url, config);
+ },
+
+ put: async (url: string, data: object) => {
+ return axiosInstance.put(url, data);
+ },
+
+ delete: async (url: string) => {
+ return axiosInstance.delete(url);
+ },
+};
diff --git a/v2/frontend/src/api/messages.ts b/v2/frontend/src/api/messages.ts
new file mode 100644
index 0000000..a8bbddd
--- /dev/null
+++ b/v2/frontend/src/api/messages.ts
@@ -0,0 +1,17 @@
+import { service } from "./axios.custom";
+
+export const sendMessage = async (data: object) => {
+ return service.post(`/messages`, data, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ });
+};
+
+export const getMessages = async (params: object) => {
+ return service.get(`/messages`, { params });
+};
+
+export const updateMessage = async (userId: number, data: object) => {
+ return service.put(`/messages/${userId}`, data);
+};
diff --git a/v2/frontend/src/api/users.ts b/v2/frontend/src/api/users.ts
new file mode 100644
index 0000000..1fe863e
--- /dev/null
+++ b/v2/frontend/src/api/users.ts
@@ -0,0 +1,28 @@
+import { service } from "./axios.custom";
+
+export const checkAuth = async () => {
+ return service.get("/users/auth");
+};
+
+export const login = async (data: object) => {
+ return service.post("/users/login", data);
+};
+
+export const register = async (data: object) => {
+ return service.post("/users/register", data);
+};
+
+export const searchGroup = async (params: object) => {
+ return service.get(`/users/search/group`, { params });
+};
+
+export const searchName = async (params: object) => {
+ return service.get(`/users/search/name`, { params });
+};
+
+
+// 벡엔드에서 구현 필요
+//localStorage.clear();
+export const logout = async () => {
+ return service.get("/users/logout");
+}
\ No newline at end of file
diff --git a/v2/frontend/src/assets/images/close-icon.svg b/v2/frontend/src/assets/images/close-icon.svg
new file mode 100644
index 0000000..21b08b0
--- /dev/null
+++ b/v2/frontend/src/assets/images/close-icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/v2/frontend/src/assets/images/newYear.svg b/v2/frontend/src/assets/images/newYear.svg
new file mode 100644
index 0000000..2748a8e
--- /dev/null
+++ b/v2/frontend/src/assets/images/newYear.svg
@@ -0,0 +1 @@
+
diff --git a/v2/frontend/src/components/ImageUploader.tsx b/v2/frontend/src/components/ImageUploader.tsx
new file mode 100644
index 0000000..4ff2dce
--- /dev/null
+++ b/v2/frontend/src/components/ImageUploader.tsx
@@ -0,0 +1,88 @@
+import { useRef } from "react";
+import styled from "styled-components";
+
+const ImageUploader = ({
+ setFile,
+ file,
+}: {
+ setFile: React.Dispatch>;
+ file: File | null;
+}) => {
+ const FILE_SIZE_MAX_LIMIT = 30 * 1024 * 1024; // 30MB
+ const fileInputRef = useRef(null);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const target = e.target;
+ const inputFile = e.target.files?.[0];
+ if (!inputFile) {
+ return;
+ }
+
+ if (!inputFile.name.match(/\.(jpg|jpeg|png)$/)) {
+ target.value = "";
+ alert("jpg, jpeg, png 형식의 파일만 업로드 가능합니다.");
+ return;
+ }
+
+ if (inputFile.size > FILE_SIZE_MAX_LIMIT) {
+ target.value = "";
+ alert("이미지 파일은 30MB를 넘을 수 없습니다.");
+ return;
+ }
+
+ setFile(inputFile);
+ };
+
+ const handleFileRemove = () => {
+ setFile(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ return (
+
+
+ {file && (
+
+ x
+
+ )}
+
+ );
+};
+
+const FileFormStyled = styled.form`
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ font-size: 8px;
+`;
+
+const FileInputStyled = styled.input`
+ font-size: 12px;
+ &::file-selector-button {
+ width: 70px;
+ height: 25px;
+ border: 1px solid var(--ref-gray-400);
+ border-radius: 4px;
+ background: var(--ref-white);
+ cursor: pointer;
+ }
+`;
+
+const DeleteButtonStyled = styled.button`
+ width: 25px;
+ height: 100%;
+ padding-bottom: 3px;
+ color: var(--ref-red-100);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+`;
+
+export default ImageUploader;
diff --git a/v2/frontend/src/components/ListPage/ListHeader.tsx b/v2/frontend/src/components/ListPage/ListHeader.tsx
new file mode 100644
index 0000000..64655c4
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/ListHeader.tsx
@@ -0,0 +1,23 @@
+import styled from "styled-components";
+
+const ListHeader = () => {
+ return (
+ <>
+ 덕담 보기
+ >
+ );
+};
+
+export default ListHeader;
+
+const TitleContainerStyled = styled.header`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-bottom: 2px solid var(--service-man-title-border-btm-color);
+ margin-bottom: 70px;
+ font-weight: 700;
+ font-size: 1.25rem;
+ letter-spacing: -0.02rem;
+ margin-bottom: 20px;
+`;
diff --git a/v2/frontend/src/components/ListPage/ListInterfaces.tsx b/v2/frontend/src/components/ListPage/ListInterfaces.tsx
new file mode 100644
index 0000000..3916a7d
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/ListInterfaces.tsx
@@ -0,0 +1,10 @@
+interface Message {
+ id: number;
+ senderName: string;
+ receiverName: string;
+ context: string;
+ imageUrl: string;
+ mine: boolean;
+}
+
+export default Message;
diff --git a/v2/frontend/src/components/ListPage/ListSection.tsx b/v2/frontend/src/components/ListPage/ListSection.tsx
new file mode 100644
index 0000000..8760649
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/ListSection.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+import { Filter, LIST_SIZE } from "../../constant";
+import Message from "./ListInterfaces";
+import CategoryButtons from "./section/CategoryButtons";
+import MessageList from "./section/MessageList";
+import MoreButtons from "./section/MoreButtons";
+import { getMessages } from "../../api/messages";
+import { useLoaderData } from "react-router";
+
+const ListSection = () => {
+ const loaderData = useLoaderData();
+ const [items, setItems] = useState(loaderData.data.messages);
+ const [nextPage, setNextPage] = useState(loaderData.data.currentPage + 1);
+ const [isLoading, setIsLoading] = useState(false);
+ const [category, setCategory] = useState(Filter.TO_EVERYONE);
+ const [isLast, setIsLast] = useState(false);
+
+ const fetchItems = async () => {
+ if (isLast || isLoading) return;
+
+ try {
+ setIsLoading(true);
+ const res = await getMessages({
+ page: nextPage,
+ size: LIST_SIZE,
+ category,
+ });
+
+ setItems((prev) => [...prev, ...res.data.messages]);
+ setNextPage(res.data.currentPage + 1);
+ setIsLast(res.data.currentPage + 1 >= res.data.totalLength);
+ } catch (error) {
+ console.error("Failed to fetch messages:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const fetchCategoryItems = async (newCategory: Filter) => {
+ try {
+ setIsLoading(true);
+ const res = await getMessages({
+ page: 0,
+ size: LIST_SIZE,
+ category: newCategory,
+ });
+
+ setItems(res.data.messages);
+ setNextPage(res.data.currentPage + 1);
+ setIsLast(res.data.currentPage + 1 >= res.data.totalLength);
+ } catch (error) {
+ console.error("Failed to fetch messages:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleChangedCategory = (newCategory: Filter) => {
+ setCategory(newCategory);
+ fetchCategoryItems(newCategory);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default ListSection;
diff --git a/v2/frontend/src/components/ListPage/ListTopNav.tsx b/v2/frontend/src/components/ListPage/ListTopNav.tsx
new file mode 100644
index 0000000..87b2cf1
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/ListTopNav.tsx
@@ -0,0 +1,22 @@
+import { Link } from "react-router";
+import styled from "styled-components";
+
+const ListTopNav = () => {
+ return (
+ <>
+
+ 덕담 보내러 가기
+
+ >
+ );
+};
+
+const LinkWrapperStyled = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ color: var(--ref-purple-500);
+ font-size: 0.875rem;
+`;
+
+export default ListTopNav;
diff --git a/v2/frontend/src/components/ListPage/section/CategoryButtons.tsx b/v2/frontend/src/components/ListPage/section/CategoryButtons.tsx
new file mode 100644
index 0000000..d55f63c
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/section/CategoryButtons.tsx
@@ -0,0 +1,58 @@
+import styled from "styled-components";
+import { Filter } from "../../../constant";
+
+interface FilterButtonProps {
+ currentCategory: Filter;
+ handleChangedCategory: (category: Filter) => void;
+}
+
+const CategoryButtons = ({
+ currentCategory,
+ handleChangedCategory,
+}: FilterButtonProps) => {
+ return (
+
+ handleChangedCategory(Filter.TO_EVERYONE)}
+ $isActived={currentCategory === Filter.TO_EVERYONE}
+ >
+ To.everyone
+
+ handleChangedCategory(Filter.TO_ME)}
+ $isActived={currentCategory === Filter.TO_ME}
+ >
+ To.me
+
+ handleChangedCategory(Filter.FROM_ME)}
+ $isActived={currentCategory === Filter.FROM_ME}
+ >
+ From.me
+
+
+ );
+};
+
+const ButtonWrapper = styled.div`
+ background-color: var(--ref-gray-300);
+ width: fit-content;
+ height: fit-content;
+ border-radius: 10px;
+ margin: 10px 0;
+`;
+
+const ButtonStyled = styled.button<{ $isActived: boolean }>`
+ background-color: ${(props) =>
+ props.$isActived ? `var(--ref-purple-500)` : `var(--ref-gray-300)`};
+ width: fit-content;
+ height: 30px;
+ border-radius: 10px;
+ padding: 5px 10px;
+ color: ${(props) =>
+ props.$isActived ? `var(--ref-white)` : `var(--ref-black)`};
+ font-size: 14px;
+ font-weight: 600;
+`;
+
+export default CategoryButtons;
diff --git a/v2/frontend/src/components/ListPage/section/MessageBox.tsx b/v2/frontend/src/components/ListPage/section/MessageBox.tsx
new file mode 100644
index 0000000..ba4f141
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/section/MessageBox.tsx
@@ -0,0 +1,79 @@
+import { styled } from "styled-components";
+import Message from "../ListInterfaces";
+
+interface MessageBoxProps {
+ item: Message;
+ setShowModal: (value: boolean) => void;
+ setModalImgUrl: (value: string) => void;
+}
+
+const MessageBox = ({
+ item: { senderName, receiverName, context, imageUrl },
+ setShowModal,
+ setModalImgUrl,
+}: MessageBoxProps) => {
+ return (
+ <>
+
+ {
+ if (imageUrl === "") return;
+ setShowModal(true);
+ setModalImgUrl(imageUrl);
+ }}
+ />
+
+
+ from {senderName} to {receiverName}
+
+
+ {context}
+
+
+
+ >
+ );
+};
+
+const MessageBoxStyled = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 1rem;
+ border: 1px solid var(--ref-gray-300);
+ border-radius: 28px;
+ margin-bottom: 1rem;
+`;
+
+const ImgStyled = styled.img<{ $hasUrl: boolean }>`
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin: 10px 30px 10px 10px;
+ ${(props) => props.$hasUrl && "cursor: pointer;"}
+`;
+
+const TextStyled = styled.div`
+ flex-grow: 1;
+`;
+
+const SenderReceiverStyled = styled.div`
+ width: 100%;
+ color: var(--ref-gray-500);
+ font-size: 14px;
+`;
+
+const ContextWrapperStyled = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+const ContextStyled = styled.div`
+ font-size: 20px;
+ font-weight: 700;
+ padding-top: 4px;
+`;
+
+export default MessageBox;
diff --git a/v2/frontend/src/components/ListPage/section/MessageBoxEditable.tsx b/v2/frontend/src/components/ListPage/section/MessageBoxEditable.tsx
new file mode 100644
index 0000000..17c05fb
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/section/MessageBoxEditable.tsx
@@ -0,0 +1,155 @@
+import { useState } from "react";
+import styled from "styled-components";
+import { updateMessage } from "../../../api/messages";
+import Message from "../ListInterfaces";
+
+interface MessageBoxProps {
+ item: Message;
+ setShowModal: (value: boolean) => void;
+ setModalImgUrl: (value: string) => void;
+}
+
+const MessageBoxEditable = ({
+ item: { id: messageId, senderName, receiverName, context, imageUrl },
+ setShowModal,
+ setModalImgUrl,
+}: MessageBoxProps) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState(context);
+ const [contextValue, setContextValue] = useState(context);
+
+ const handleEdit = async () => {
+ if (isEditing) {
+ if (inputValue !== context) {
+ try {
+ const res = await updateMessage(messageId, { context: inputValue });
+ if (res.status === 200) {
+ setContextValue(inputValue);
+ }
+ } catch (error) {
+ console.error("Failed to update message:", error);
+ setInputValue(contextValue);
+ return;
+ }
+ }
+ }
+ setIsEditing(!isEditing);
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ };
+
+ const handleCancel = () => {
+ setInputValue(contextValue);
+ setIsEditing(false);
+ };
+
+ return (
+ <>
+
+ {
+ if (imageUrl === "") return;
+ setShowModal(true);
+ setModalImgUrl(imageUrl);
+ }}
+ />
+
+
+ from {senderName} to {receiverName}
+
+
+ {isEditing ? (
+
+ ) : (
+ {contextValue}
+ )}
+
+
+ {isEditing ? "완료" : "수정"}
+
+ {isEditing && (
+ 취소
+ )}
+
+
+
+
+ >
+ );
+};
+
+const MessageBoxStyled = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 1rem;
+ border: 1px solid var(--ref-gray-300);
+ border-radius: 28px;
+ margin-bottom: 1rem;
+`;
+
+const ImgStyled = styled.img<{ $hasUrl: boolean }>`
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin: 10px 30px 10px 10px;
+ ${(props) => props.$hasUrl && "cursor: pointer;"}
+`;
+
+const TextStyled = styled.div`
+ flex-grow: 1;
+`;
+
+const SenderReceiverStyled = styled.div`
+ width: 100%;
+ color: var(--ref-gray-500);
+ font-size: 14px;
+`;
+
+const ContextWrapperStyled = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+const ContextStyled = styled.div`
+ font-size: 20px;
+ font-weight: 700;
+ padding-top: 4px;
+`;
+
+const InputStyled = styled.input`
+ font-size: 20px;
+ font-weight: 700;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ width: 100%;
+ margin-right: 10px;
+ text-align: left;
+`;
+
+const ButtonGroupStyled = styled.div`
+ display: flex;
+ gap: 8px;
+`;
+
+const ButtonStyled = styled.button`
+ background-color: #f0f0f0;
+ width: 60px;
+ height: 36px;
+ border: none;
+ border-radius: 10px;
+ padding: 6px 12px;
+ font-size: 14px;
+ color: #666666;
+`;
+
+export default MessageBoxEditable;
diff --git a/v2/frontend/src/components/ListPage/section/MessageList.tsx b/v2/frontend/src/components/ListPage/section/MessageList.tsx
new file mode 100644
index 0000000..2b12f75
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/section/MessageList.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import styled from "styled-components";
+import Message from "../ListInterfaces";
+import MessageBox from "./MessageBox";
+import MessageBoxEditable from "./MessageBoxEditable";
+import { ReactComponent as CloseIcon } from "../../../assets/images/close-icon.svg";
+
+interface MessageListProps {
+ items: Message[];
+ isLoading: boolean;
+}
+
+const MessageList = ({ items, isLoading }: MessageListProps) => {
+ const [showModal, setShowModal] = useState(false);
+ const [modalImgUrl, setModalImgUrl] = useState("");
+
+ if (items.length === 0 && !isLoading) {
+ return 메시지가 없습니다;
+ }
+
+ return (
+ <>
+
+ {items.map((item) =>
+ item.mine ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ {showModal && (
+ setShowModal(false)}>
+ e.stopPropagation()}
+ />
+ setShowModal(false)}>
+
+
+
+ )}
+ >
+ );
+};
+
+const MessageBoxStyled = styled.div`
+ margin-top: 32px;
+`;
+
+const EmptyState = styled.div`
+ text-align: center;
+ padding: 2rem;
+ color: var(--ref-gray-600);
+`;
+
+const ModalOverlay = styled.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--ref-black);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`;
+
+const ModalImage = styled.img`
+ max-width: fit-content;
+ max-height: 90%;
+ border-radius: 8px;
+ object-fit: contain;
+`;
+
+const CloseButton = styled.button`
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: white;
+ border: none;
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+`;
+
+export default MessageList;
diff --git a/v2/frontend/src/components/ListPage/section/MoreButtons.tsx b/v2/frontend/src/components/ListPage/section/MoreButtons.tsx
new file mode 100644
index 0000000..73ce140
--- /dev/null
+++ b/v2/frontend/src/components/ListPage/section/MoreButtons.tsx
@@ -0,0 +1,39 @@
+import styled from "styled-components";
+
+interface MoreButtonsProps {
+ fetchItems: () => void;
+ isLoading: boolean;
+ isLast: boolean;
+}
+
+const MoreButtons = ({ fetchItems, isLoading, isLast }: MoreButtonsProps) => {
+ return (
+
+ {isLoading ? `Loading...` : `더보기`}{" "}
+
+ );
+};
+
+const MoreButtonStyled = styled.button<{ $isLast: boolean }>`
+ display: ${(props) => (props.$isLast ? "none" : "block")};
+ width: 100%;
+ padding: 12px;
+ margin: 20px 0;
+ border-radius: 8px;
+ background: #9747ff;
+ font-size: 16px;
+ font-weight: 500;
+ color: white;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: #8010ff;
+ }
+
+ &:active {
+ transform: scale(0.98);
+ }
+`;
+
+export default MoreButtons;
diff --git a/v2/frontend/src/components/PrivateRoute.tsx b/v2/frontend/src/components/PrivateRoute.tsx
new file mode 100644
index 0000000..15efbb6
--- /dev/null
+++ b/v2/frontend/src/components/PrivateRoute.tsx
@@ -0,0 +1,41 @@
+import { ReactElement, useEffect, useState } from "react";
+import { checkAuth } from "../api/users";
+import { Navigate, useLocation } from "react-router";
+
+const PrivateRoute = ({
+ children,
+}: {
+ children: ReactElement;
+}): ReactElement => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const location = useLocation();
+
+ useEffect(() => {
+ // auth check - call "/user/auth" API with token
+ const checkAuthStatus = async () => {
+ try {
+ const res = await checkAuth();
+ setIsAuthenticated(res.status === 200);
+ } catch (error) {
+ setIsAuthenticated(false);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ checkAuthStatus();
+ }, [location.pathname]);
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return children;
+};
+
+export default PrivateRoute;
diff --git a/v2/frontend/src/components/PublicRoute.tsx b/v2/frontend/src/components/PublicRoute.tsx
new file mode 100644
index 0000000..f01221c
--- /dev/null
+++ b/v2/frontend/src/components/PublicRoute.tsx
@@ -0,0 +1,41 @@
+import { ReactElement, useEffect, useState } from "react";
+import { checkAuth } from "../api/users";
+import { Navigate, useLocation } from "react-router";
+
+const PublicRoute = ({
+ children,
+}: {
+ children: ReactElement;
+}): ReactElement => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const location = useLocation();
+
+ useEffect(() => {
+ // auth check - call "/user/auth" API with token
+ const checkAuthStatus = async () => {
+ try {
+ const res = await checkAuth();
+ setIsAuthenticated(res.status === 200);
+ } catch (error) {
+ setIsAuthenticated(false);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ checkAuthStatus();
+ }, [location.pathname]);
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return children;
+};
+
+export default PublicRoute;
diff --git a/v2/frontend/src/components/SearchInputField.tsx b/v2/frontend/src/components/SearchInputField.tsx
new file mode 100644
index 0000000..6b3bd87
--- /dev/null
+++ b/v2/frontend/src/components/SearchInputField.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from "react";
+import styled from "styled-components";
+import { searchGroup, searchName } from "../api/users";
+import SearchResultStyled from "./SearchResult";
+
+const SearchInputField = ({
+ setSearchInputText,
+}: {
+ setSearchInputText: (searchTerm: string) => void;
+}) => {
+ const [isFocused, setIsFocused] = useState(false);
+ const [searchList, setSearchList] = useState([]);
+ const [inputValue, setInputValue] = useState("");
+
+ useEffect(() => {
+ const debounceTimer = setTimeout(async () => {
+ if (inputValue == "") {
+ setSearchList([]);
+ return;
+ }
+ try {
+ if (inputValue[0] === "@") {
+ const searchTerm = inputValue.slice(1).trim();
+ const res = await searchGroup({ input: searchTerm });
+ setSearchList(res.data.groupNames);
+ } else {
+ const res = await searchName({ input: inputValue });
+ console.log(res.data.names);
+ setSearchList(res.data.names);
+ }
+ } catch (error: any) {
+ alert(error.response.data.message);
+ setSearchList([]);
+ }
+ }, 500);
+
+ return () => {
+ clearTimeout(debounceTimer);
+ };
+ }, [inputValue]);
+
+ const handleSearch = async (e: { target: { value: string } }) => {
+ const value = e.target.value;
+ setInputValue(value);
+ setSearchInputText(value);
+ };
+
+ const setSearchName = (value: string) => {
+ setInputValue(value);
+ setSearchInputText(value);
+ setIsFocused(false);
+ };
+
+ return (
+ <>
+
+ setIsFocused(true)}
+ onBlur={() => setTimeout(() => setIsFocused(false), 200)}
+ $isFocus={isFocused}
+ />
+ {isFocused && (
+
+
+ )}
+
+ >
+ );
+};
+
+const SearchWrapperStyled = styled.div`
+ width: 100%;
+ position: relative;
+`;
+
+const SearchInputFieldStyled = styled.input<{ $isFocus: boolean }>`
+ width: 100%;
+ height: 40px;
+ background-color: var(--ref-white);
+ border-radius: 8px;
+ text-align: left;
+ padding: 10px;
+ border-radius: 8px;
+ border: 2px solid
+ ${({ $isFocus }) =>
+ $isFocus ? "var(--ref-purple-500)" : "var(--ref-white)"};
+ text-align: left;
+`;
+
+export default SearchInputField;
diff --git a/v2/frontend/src/components/SearchResult.tsx b/v2/frontend/src/components/SearchResult.tsx
new file mode 100644
index 0000000..353247f
--- /dev/null
+++ b/v2/frontend/src/components/SearchResult.tsx
@@ -0,0 +1,56 @@
+import styled from "styled-components";
+
+const SearchResult = ({
+ searchList,
+ setSearchName,
+}: {
+ searchList: string[];
+ setSearchName: (value: string) => void;
+}) => {
+ return (
+
+ {searchList.length > 0 && (
+
+ {searchList.map((result) => (
+ setSearchName(result)}
+ >
+ {result}
+
+ ))}
+
+ )}
+
+ );
+};
+
+const SearchResultStyled = styled.div`
+ width: 100%;
+ position: absolute;
+ z-index: 1000;
+ border-radius: 8px;
+ background-color: var(--ref-white);
+`;
+
+const SearchUlStyled = styled.ul`
+ min-height: 30px;
+ padding-left: 0px;
+`;
+
+const SearchLiStyled = styled.li`
+ height: 30px;
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ padding-left: 10px;
+ padding-bottom: 3px;
+ border-radius: 8px;
+ cursor: pointer;
+ &:hover {
+ background-color: var(--ref-purple-500);
+ color: var(--ref-white);
+ }
+`;
+
+export default SearchResult;
\ No newline at end of file
diff --git a/v2/frontend/src/components/UserInputField.tsx b/v2/frontend/src/components/UserInputField.tsx
new file mode 100644
index 0000000..b524e55
--- /dev/null
+++ b/v2/frontend/src/components/UserInputField.tsx
@@ -0,0 +1,66 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type LoginInputFieldProps = {
+ type?: string;
+ value: string;
+ pattern?: string;
+ autocomplete?: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+};
+
+const LoginInputField: React.FC = ({
+ type,
+ value,
+ pattern,
+ autocomplete,
+ onChange,
+ placeholder,
+}) => {
+ const [isFocused, setIsFocused] = useState(false);
+
+ return (
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ />
+
+ );
+};
+
+const LoginInputFieldWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ margin-bottom: 0.8rem;
+`;
+
+const LoginInputFieldStyled = styled.input<{ $isFocus: boolean }>`
+ width: 350px;
+ height: 40px;
+ padding: 0.5rem 0.8rem;
+ text-align: left;
+ font-size: 1rem;
+ border: 2px solid
+ ${({ $isFocus }) =>
+ $isFocus ? "var(--ref-purple-500)" : "var(--ref-gray-300)"};
+ border-radius: 4px;
+ outline: none;
+ transition: border-color 0.3s;
+
+ &::placeholder {
+ color: var(--ref-gray-500);
+ }
+`;
+
+export default LoginInputField;
diff --git a/v2/frontend/src/constant.ts b/v2/frontend/src/constant.ts
new file mode 100644
index 0000000..c5ed920
--- /dev/null
+++ b/v2/frontend/src/constant.ts
@@ -0,0 +1,7 @@
+export enum Filter {
+ TO_EVERYONE = 0,
+ TO_ME = 1,
+ FROM_ME = 2,
+}
+
+export const LIST_SIZE = 5;
diff --git a/v2/frontend/src/custom.d.ts b/v2/frontend/src/custom.d.ts
new file mode 100644
index 0000000..b1d9a52
--- /dev/null
+++ b/v2/frontend/src/custom.d.ts
@@ -0,0 +1,7 @@
+declare module "*.svg" {
+ import * as React from "react";
+
+ export const ReactComponent: React.FunctionComponent<
+ React.SVGProps & { title?: string }
+ >;
+}
diff --git a/v2/frontend/src/index.css b/v2/frontend/src/index.css
new file mode 100644
index 0000000..9cad229
--- /dev/null
+++ b/v2/frontend/src/index.css
@@ -0,0 +1,222 @@
+@import url("https://fonts.googleapis.com/css2?family=Do+Hyeon&display=swap&text=새롬관로그인중수요지식회여기엔사물함이없어4층은현재용불가입니다!");
+@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&display=swap");
+
+:root {
+ /* white, gray, black */
+ --ref-white: #ffffff;
+ --ref-gray-100: #f5f5f5;
+ --ref-gray-200: #eeeeee;
+ --ref-gray-300: #d9d9d9;
+ --ref-gray-400: #bcbcbc;
+ --ref-gray-500: #7b7b7b;
+ --ref-gray-600: #525252;
+ --ref-gray-700: #3c3c3c;
+ --ref-gray-800: #2e2e2e;
+ --ref-gray-900: #1f1f1f;
+ --ref-black: #181818;
+
+ /* red */
+ --ref-red-100: #ff0000;
+ --ref-red-200: #ff4e4e;
+ --ref-red-300: #e54646;
+
+ /* orange */
+ --ref-orange-100: #ff8b5b;
+ --ref-orange-200: #ef8172;
+
+ /* yellow */
+ --ref-yellow: #ffc74c;
+
+ /* green */
+ --ref-green-100: #47ffa7;
+ --ref-green-200: #3fe596;
+ --ref-green-300: #00cec9;
+ --ref-green-400: #00c2ab;
+
+ /* blue */
+ --ref-blue-100: #f5f7ff;
+ --ref-blue-200: #dce7fd;
+ --ref-blue-300: #91b5fa;
+ --ref-blue-400: #7ebffb;
+ --ref-blue-500: #5278fd;
+ --ref-blue-600: #3f69fd;
+ --ref-blue-700: #2c5afd;
+ --ref-blue-800: #26447e;
+ --ref-blue-900: #252526;
+
+ /* purple */
+ --ref-purple-100: #f9f6ff;
+ --ref-purple-200: #dfd0fe;
+ --ref-purple-300: #a29bfe;
+ --ref-purple-400: #b18cff;
+ --ref-purple-500: #9747ff;
+ --ref-purple-600: #8337e5;
+ --ref-purple-700: #6931b2;
+ --ref-purple-800: #3c1c66;
+ --ref-purple-900: #252425;
+
+ --ref-transparent-purple-100: #b18cff5f;
+ --ref-transparent-purple-200: #b08cffe1;
+
+ /* pink */
+ --ref-pink-100: #f473b1;
+ --ref-pink-200: #ff4589;
+ --ref-pink-300: #d72766;
+
+ /* shadow color */
+ --ref-black-shadow-100: rgba(0, 0, 0, 0.1);
+ --ref-black-shadow-200: rgba(0, 0, 0, 0.25);
+ --ref-black-shadow-300: rgba(0, 0, 0, 0.4);
+ --ref-black-shadow-400: rgba(0, 0, 0, 0.8);
+
+ /* font variable */
+ --main-font: "Noto Sans KR", sans-serif;
+ --building-font: "Do Hyeon", sans-serif;
+ --size-base: 14px;
+
+ /* default setting */
+ font-family: "Noto Sans KR", Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+ display: swap;
+}
+
+a {
+ color: var(--sys-main-color);
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ a:hover {
+ opacity: 0.9;
+ }
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ user-select: none;
+ overflow: hidden;
+}
+
+/* iOS Pinch Zoom disabled */
+body {
+ touch-action: none;
+}
+
+#root {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ background-color: var(--bg-color);
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ background-color: var(--sys-main-color);
+ color: var(--white-text-with-bg-color);
+ width: 200px;
+ height: 60px;
+ font-size: 1.125rem;
+ text-align: center;
+ font-family: var(--main-font);
+ border-radius: 6px;
+ font-weight: 300;
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ button:hover {
+ opacity: 0.9;
+ }
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.9;
+}
+
+img {
+ -webkit-user-drag: none;
+ max-width: 100%;
+ width: 100%;
+ height: 100%;
+ vertical-align: top;
+ object-fit: cover;
+}
+
+input {
+ color: var(--normal-text-color);
+ text-align: center;
+ border: none;
+ font-size: 1rem;
+ outline: none;
+ background: none;
+ box-sizing: border-box;
+}
+
+textarea {
+ outline: none;
+ box-sizing: border-box;
+}
+
+.modal {
+ border-radius: 10px;
+ box-shadow: 10px 10px 40px 0px var(--login-card-border-shadow-color);
+}
+
+.textNowrap {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.noScrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.noScrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+.blind {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ clip: rect(0 0 0 0);
+ overflow: hidden;
+}
+
+.clear::after {
+ display: block;
+ clear: both;
+ content: "";
+}
+
+.leftNavButtonActive {
+ background: var(--sys-main-color);
+ color: var(--white-text-with-bg-color) !important;
+}
+
+.cabiButton {
+ -webkit-tap-highlight-color: transparent;
+}
+
+.domainButtonActive {
+ color: var(--sys-main-color) !important;
+}
diff --git a/v2/frontend/src/pages/ListPage.tsx b/v2/frontend/src/pages/ListPage.tsx
new file mode 100644
index 0000000..5860cca
--- /dev/null
+++ b/v2/frontend/src/pages/ListPage.tsx
@@ -0,0 +1,43 @@
+import ListTopNav from "../components/ListPage/ListTopNav";
+import ListHeader from "../components/ListPage/ListHeader";
+import ListSection from "../components/ListPage/ListSection";
+import styled from "styled-components";
+
+const ListPage = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const WrapperStyled = styled.div`
+ font-family: "Noto Sans KR", sans-serif;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 60px 0;
+ overflow: scroll;
+
+ & > * {
+ width: 80%;
+ max-width: 1000px;
+ }
+`;
+
+const NavStyled = styled.nav`
+ width: 80%;
+`;
+
+export default ListPage;
diff --git a/v2/frontend/src/pages/LoginPage.tsx b/v2/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..f8f0843
--- /dev/null
+++ b/v2/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,103 @@
+import { useEffect, useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import styled from "styled-components";
+import UserInputField from "../components/UserInputField";
+import { ReactComponent as NewYearImg } from "../assets/images/newYear.svg";
+import { login } from "../api/users";
+
+const LoginPage = () => {
+ const [id, setId] = useState("");
+ const [pw, setPw] = useState("");
+ const navigate = useNavigate();
+ const idRegex = /^[A-Za-z0-9]{1,10}$/;
+ const pwRegex = /^(?!.*(.)\1{3})[A-Za-z0-9]+$/;
+
+ const handleLogin = async () => {
+ if (!idRegex.test(id)) {
+ alert("형식에 맞지 않는 아이디입니다.");
+ return;
+ }
+ if (!pwRegex.test(pw)) {
+ alert("형식에 맞지 않는 비밀번호입니다.");
+ return;
+ }
+
+ try {
+ const data = { name: id, password: pw };
+ const response = await login(data);
+ if (response.status === 200) {
+ navigate("/");
+ }
+ } catch (error: any) {
+ alert(error.response.data.message);
+ }
+ };
+
+ return (
+
+
+ 로그인
+
+ 회원가입
+ 로그인
+
+ );
+};
+
+const LoginPageStyled = styled.div`
+ font-family: "Noto Sans KR", sans-serif;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100vh;
+ background-color: var(--ref-gray-100);
+`;
+
+const LoginTitleStyled = styled.div`
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 3rem;
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+`;
+
+const LinkStyled = styled(Link)`
+ font-size: 1rem;
+ color: var(--ref-purple-500);
+ line-height: 2rem;
+ margin-top: 1rem;
+`;
+
+const LoginButtonStyled = styled.button`
+ width: 350px;
+ height: 54px;
+ padding: 0.8rem 1rem;
+ background-color: var(--ref-purple-500);
+ color: var(--ref-white);
+ font-size: 1rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-top: 1rem;
+`;
+
+export default LoginPage;
diff --git a/v2/frontend/src/pages/RegisterPage.tsx b/v2/frontend/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..ad6717f
--- /dev/null
+++ b/v2/frontend/src/pages/RegisterPage.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import { ReactComponent as NewYearImg } from "../assets/images/newYear.svg";
+import styled from "styled-components";
+import UserInputField from "../components/UserInputField";
+import { register } from "../api/users";
+import { useNavigate } from "react-router";
+
+const RegisterPage = () => {
+ const [id, setId] = useState("");
+ const [pw, setPw] = useState("");
+ const navigate = useNavigate();
+ const idRegex = /^[A-Za-z0-9]{1,10}$/;
+ const pwRegex = /^(?!.*(.)\1{3})[A-Za-z0-9]+$/;
+
+ const handleRegister = async () => {
+ if (!idRegex.test(id)) {
+ alert("형식에 맞지 않는 아이디입니다.");
+ return;
+ }
+ if (!pwRegex.test(pw)) {
+ alert("형식에 맞지 않는 비밀번호입니다.");
+ return;
+ }
+
+ try {
+ const data = { name: id, password: pw };
+ const response = await register(data);
+ if (response.status === 200) {
+ navigate("/login");
+ }
+ } catch (error: any) {
+ alert(error.response.data.message);
+ }
+ };
+ return (
+
+
+ 회원가입
+
+ 비밀번호는 변경할 수 없습니다
+
+ 회원가입
+
+
+ );
+};
+
+const RegisterPageStyled = styled.div`
+ font-family: "Noto Sans KR", sans-serif;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100vh;
+ background-color: var(--ref-gray-100);
+`;
+
+const RegisterTitleStyled = styled.div`
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 3rem;
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+`;
+
+const RegisterTextStyled = styled.div`
+ font-size: 1rem;
+ color: var(--ref-gray-500);
+ line-height: 2rem;
+ margin-top: 1rem;
+`;
+
+const RegisterButtonStyled = styled.button`
+ width: 350px;
+ height: 54px;
+ padding: 0.8rem 1rem;
+ background-color: var(--ref-purple-500);
+ color: var(--ref-white);
+ font-size: 1rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-top: 1rem;
+`;
+
+export default RegisterPage;
diff --git a/v2/frontend/src/pages/SendPage.tsx b/v2/frontend/src/pages/SendPage.tsx
new file mode 100644
index 0000000..41a24da
--- /dev/null
+++ b/v2/frontend/src/pages/SendPage.tsx
@@ -0,0 +1,206 @@
+import styled from "styled-components";
+import { ChangeEvent, useRef, useState } from "react";
+import SearchInputField from "../components/SearchInputField";
+import { Link, useNavigate } from "react-router";
+import ImageUploader from "../components/ImageUploader";
+import { sendMessage } from "../api/messages";
+
+const SendPage = () => {
+ const [searchInputText, setSearchInputText] = useState("");
+ const messageTextAreaRef = useRef(null);
+ const [text, setText] = useState("");
+ const [textLength, setTextLength] = useState(0);
+ const [isFocused, setIsFocused] = useState(false);
+ const [file, setFile] = useState(null);
+ const navigate = useNavigate();
+ const maxLength = 42;
+
+ const handleChanged = (e: ChangeEvent) => {
+ // 글자 폭에 따른 자동 길이 조절
+ const currentValue = messageTextAreaRef.current;
+ if (!currentValue) return;
+ currentValue.style.height = "auto";
+ currentValue.style.height = `${currentValue.scrollHeight}px`;
+
+ // 글자 수 세기 및 제한
+ const inputText = e.target.value.slice(0, maxLength);
+ setText(inputText);
+ setTextLength(inputText.length);
+ };
+
+ const handleSubmit = async () => {
+ if (!searchInputText) return alert("받는이를 입력해주세요.");
+ if (!messageTextAreaRef.current?.value)
+ return alert("메시지 내용을 입력해주세요.");
+ const formData = new FormData();
+
+ formData.append("receiverName", searchInputText);
+ formData.append("context", messageTextAreaRef.current?.value || "");
+ if (file) {
+ formData.append("image", file);
+ }
+
+ console.log(formData);
+ try {
+ await sendMessage(formData);
+ alert("메시지가 성공적으로 전송되었습니다.");
+ navigate("/");
+ } catch (error) {
+ alert(error);
+ console.log(error);
+ }
+ };
+
+ return (
+
+
+ 덕담 보러 가기
+
+ 덕담 보내기
+
+
+
+
+ 받는이 ( Intra ID / @everyone ) *
+
+
+
+
+
+ 메시지 내용 *
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ />
+
+ {textLength} / {maxLength}
+
+
+
+
+ 사진 ( jpg, jpeg, png )
+
+
+
+
+ 보내기
+
+
+
+
+ );
+};
+
+export default SendPage;
+
+const WrapperStyled = styled.div`
+ font-family: "Noto Sans KR", sans-serif;
+ height: 500px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ align-items: center;
+ padding: 60px 0;
+`;
+
+const LinkWrapperStyled = styled.div`
+ width: 80%;
+ height: 50px;
+ display: flex;
+ justify-content: flex-end;
+ color: var(--ref-purple-500);
+ font-size: 0.875rem;
+`;
+
+const TitleContainerStyled = styled.div`
+ width: 80%;
+ max-width: 1000px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-bottom: 2px solid var(--service-man-title-border-btm-color);
+ margin-bottom: 70px;
+ font-weight: 700;
+ font-size: 1.25rem;
+ letter-spacing: -0.02rem;
+ margin-bottom: 20px;
+`;
+
+const ContainerStyled = styled.div`
+ width: 80%;
+ max-width: 1000px;
+ margin-bottom: 40px;
+`;
+
+const FormWrapperStyled = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: center;
+ background-color: var(--ref-gray-100);
+ border-radius: 10px;
+ padding: 30px 20px;
+ gap: 20px;
+`;
+
+const FormContainerStyled = styled.div`
+ width: 100%;
+`;
+const FormSubTitleStyled = styled.h3`
+ font-size: 0.875rem;
+ color: var(--ref-gray-500);
+ margin-bottom: 10px;
+ .red {
+ color: var(--ref-red-200);
+ }
+`;
+
+const SendTextFieldStyled = styled.textarea<{ $isFocus: boolean }>`
+ font-family: "Noto Sans KR", sans-serif;
+ font-size: 16px;
+ width: 100%;
+ min-height: 40px;
+ max-height: 200px;
+ resize: none;
+ background-color: var(--ref-white);
+ border-radius: 8px;
+ border: 2px solid
+ ${({ $isFocus }) => ($isFocus ? "var(--ref-purple-500)" : "transparent")};
+ text-align: left;
+ padding: 10px;
+ ::placeholder {
+ color: var(--ref-gray-400);
+ }
+`;
+
+const TextLengthStyled = styled.div`
+ font-family: "Noto Sans KR", sans-serif;
+ font-size: 12px;
+ color: var(--ref-gray-600);
+ text-align: right;
+ margin-top: 4;
+`;
+
+const FormButtonContainerStyled = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+`;
+
+const FormButtonStyled = styled.button`
+ width: 100px;
+ height: 30px;
+ font-size: 0.875rem;
+ background-color: var(--ref-purple-500);
+ color: var(--ref-white);
+ font-weight: 700;
+ border: 1px solid var(--ref-white);
+ border-radius: 4px;
+ cursor: pointer;
+`;
diff --git a/v2/frontend/src/routes.tsx b/v2/frontend/src/routes.tsx
new file mode 100644
index 0000000..6fe13b6
--- /dev/null
+++ b/v2/frontend/src/routes.tsx
@@ -0,0 +1,71 @@
+import SendPage from "./pages/SendPage";
+import LoginPage from "./pages/LoginPage";
+import RegisterPage from "./pages/RegisterPage";
+import ListPage from "./pages/ListPage";
+import { ReactElement } from "react";
+import PrivateRoute from "./components/PrivateRoute";
+import PublicRoute from "./components/PublicRoute";
+import { getMessages } from "./api/messages";
+import { Filter, LIST_SIZE } from "./constant";
+
+enum AccessType {
+ PUBLIC,
+ PRIVATE,
+}
+
+export interface RouteInfo {
+ path: string;
+ accessType: AccessType;
+ element: ReactElement;
+ loader?: () => Promise;
+}
+
+const routesInfo: RouteInfo[] = [
+ {
+ path: "/",
+ accessType: AccessType.PRIVATE,
+ element: ,
+ loader: async () => {
+ try {
+ const res = await getMessages({
+ page: 0,
+ size: LIST_SIZE,
+ category: Filter.TO_EVERYONE,
+ });
+ return res;
+ } catch (error) {
+ console.error("Failed to fetch messages:", error);
+ return null;
+ }
+ },
+ },
+ {
+ path: "/login",
+ accessType: AccessType.PUBLIC,
+ element: ,
+ },
+ {
+ path: "/register",
+ accessType: AccessType.PUBLIC,
+ element: ,
+ },
+ {
+ path: "/send",
+ accessType: AccessType.PRIVATE,
+ element: ,
+ },
+];
+
+const injectProtectedRoute = (routesInfo: RouteInfo[]) => {
+ return routesInfo.map((route: RouteInfo) => {
+ if (route.accessType === AccessType.PRIVATE) {
+ route.element = {route.element};
+ } else if (route.accessType === AccessType.PUBLIC) {
+ route.element = {route.element};
+ }
+
+ return route;
+ });
+};
+
+export const routes = injectProtectedRoute(routesInfo);
diff --git a/v2/package-lock.json b/v2/package-lock.json
new file mode 100644
index 0000000..316a452
--- /dev/null
+++ b/v2/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "v2",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}