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 ( + + + 로그인 +
+ setId(e.target.value)} + placeholder="id" + /> + setPw(e.target.value)} + placeholder="pw" + /> + + 회원가입 + 로그인 +
+ ); +}; + +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 ( + + + 회원가입 +
+ setId(e.target.value)} + placeholder="id" + /> + setPw(e.target.value)} + placeholder="pw" + /> + + 비밀번호는 변경할 수 없습니다 + + 회원가입 + +
+ ); +}; + +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": {} +}