diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 34deca928..d79763e2f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,3 @@ - - ## 📌 관련 이슈 - closed: #issueNum diff --git a/.github/workflows/auto-pull-request.yml b/.github/workflows/auto-pull-request.yml new file mode 100644 index 000000000..892bdb3d1 --- /dev/null +++ b/.github/workflows/auto-pull-request.yml @@ -0,0 +1,98 @@ +name: Auto Create Pull Request + +on: + push: + branches: + - 'feat/#*' + - 'refactor/#*' + - 'fix/#*' + +jobs: + auto-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract Branch Prefix, Issue Number + id: extract + run: | + branch_name="${GITHUB_REF#refs/heads/}" + echo "BRANCH_NAME=$branch_name" >> $GITHUB_ENV + + if [[ "$branch_name" =~ ^(feat|fix|refactor)/#([0-9]+)$ ]]; then + branch_prefix="${BASH_REMATCH[1]}" + issue_number="${BASH_REMATCH[2]}" + echo "BRANCH_PREFIX=$branch_prefix" >> $GITHUB_ENV + echo "ISSUE_NUMBER=$issue_number" >> $GITHUB_ENV + else + exit 0 + fi + + - name: Check for Already Exist + id: check_pr + run: | + branch_name=${{ env.BRANCH_NAME }} + existing_pr=$(gh pr list --state open -H "$branch_name" -B develop --json number -q '.[] | .number') + + if [ -n "$existing_pr" ]; then + echo "EXISTED=TRUE" >> $GITHUB_ENV + echo "Alreadt Exist in https://github.com/${{ github.repository }}/pull/$existing_pr" + exit 0 + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Fetch Issue Detail + if: ${{ !env.EXISTED }} + run: | + issue_number="${{ env.ISSUE_NUMBER }}" + + response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/issues/$issue_number") + + assignees=$(echo "$response" | jq -r '.assignees[].login' | tr '\n' ', ' | sed 's/, $//') + assignees=$(echo "$assignees" | rev | cut -c 2- | rev) + + title=$(echo "$response" | jq -r '.title') + + labels=$(echo "$response" | jq -r '.labels[].name' | tr '\n' ', ' | sed 's/, $//') + labels=$(echo "$labels" | rev | cut -c 2- | rev) + + pr_title="${title}(#${issue_number})" + + echo "$response" | jq -r '.body' > issue_body.txt + + echo "ASSIGNEES=$assignees" >> $GITHUB_ENV + echo "LABELS=$labels" >> $GITHUB_ENV + echo "TITLE=$title" >> $GITHUB_ENV + echo "PR_TITLE=$pr_title" >> $GITHUB_ENV + echo "ISSUE_BODY_FILE=issue_body.txt" >> $GITHUB_ENV + + - name: Generate PR Body + if: ${{ !env.EXISTED }} + id: generate-body + run: | + issue_number="${{ env.ISSUE_NUMBER }}" + + echo "## 📌 관련 이슈" >> body.md + echo "" >> body.md + echo "- closed : #${issue_number} " >> body.md + echo "" >> body.md + echo "## ✨ PR 세부 내용" >> body.md + echo "" >> body.md + echo "" >> body.md + + summary=$(cat body.md) + echo "PR_BODY<> $GITHUB_ENV + echo "$summary" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create Pull Request + if: ${{ !env.EXISTED }} + run: | + gh pr create --title "${{ env.PR_TITLE }}" --body "${{ env.PR_BODY }}" --base "develop" --label "${{ env.LABELS }}" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 000000000..c0ad1a2f4 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,56 @@ +name: Backend CI + +on: + pull_request: + branches: + - 'develop' + paths: ['backend/**'] + types: + - opened + - synchronize + - reopened + +defaults: + run: + working-directory: backend + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Repository checkout + uses: actions/checkout@v4 + + - name: Setup java 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'zulu' + + - name: Cache gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Assign grant gradlew + run: chmod +x gradlew + + - name: Test with gradle + run: ./gradlew --info test + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: '**/build/test-results/test/TEST-*.xml' + + - name: Publish test report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: '**/build/test-results/test/TEST-*.xml' diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 000000000..c53fdde6a --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,43 @@ +name: Frontend CI + +on: + pull_request: + branches: + - 'develop' + paths: ['frontend/**'] + types: + - opened + - synchronize + - reopened + +defaults: + run: + working-directory: frontend + +jobs: + build: + timeout-minutes: 10 + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + # 해당 저장소의 코드를 가져온다 + - name: Checkout + uses: actions/checkout@v4 + + # 노드 설치 + - name: Install Nodejs + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + # 패키지 설치 + - name: Install dependencies + run: npm ci + + # 테스트 + - name: Run tests + run: npm test diff --git a/.github/workflows/storybook-cd.yml b/.github/workflows/storybook-cd.yml new file mode 100644 index 000000000..ebc0683d6 --- /dev/null +++ b/.github/workflows/storybook-cd.yml @@ -0,0 +1,46 @@ +name: Frontend Storybook Deploy +on: + pull_request: + branches: + - 'develop' + paths: ['frontend/**/*.stories.ts', 'frontend/**/*.stories.tsx'] + types: + - opened + - synchronize + - reopened + +defaults: + run: + working-directory: frontend + +jobs: + chromatic: + timeout-minutes: 10 + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + # 해당 저장소의 코드를 가져온다 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # 노드 설치 + - name: Install Nodejs + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm ci + + # 스토리북 배포 + - name: Run Chromatic + uses: chromaui/action@latest + with: + workingDir: frontend + projectToken: ${{ secrets.STORY_BOOK_TOKEN }} diff --git a/backend/build.gradle b/backend/build.gradle index 9be2ddfc5..ea71c03f7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,6 +29,7 @@ dependencies { // Etc implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' // Database runtimeOnly 'com.h2database:h2' diff --git a/backend/src/main/java/corea/backend/BackendApplication.java b/backend/src/main/java/corea/BackendApplication.java similarity index 78% rename from backend/src/main/java/corea/backend/BackendApplication.java rename to backend/src/main/java/corea/BackendApplication.java index ab0ef8be6..a7eea2077 100644 --- a/backend/src/main/java/corea/backend/BackendApplication.java +++ b/backend/src/main/java/corea/BackendApplication.java @@ -1,4 +1,4 @@ -package corea.backend; +package corea; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -6,7 +6,7 @@ @SpringBootApplication public class BackendApplication { - public static void main(String[] args) { + public static void main(final String[] args) { SpringApplication.run(BackendApplication.class, args); } diff --git a/backend/src/main/java/corea/DataInitializer.java b/backend/src/main/java/corea/DataInitializer.java new file mode 100644 index 000000000..9f5702eba --- /dev/null +++ b/backend/src/main/java/corea/DataInitializer.java @@ -0,0 +1,147 @@ +package corea; + +import corea.matching.domain.Participation; +import corea.matching.repository.ParticipationRepository; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Profile("test") +@Component +@Transactional +@RequiredArgsConstructor +public class DataInitializer implements ApplicationRunner { + + private final MemberRepository memberRepository; + private final RoomRepository roomRepository; + private final ParticipationRepository participationRepository; + + @Override + public void run(ApplicationArguments args) { + Member member1 = memberRepository.save( + new Member("jcoding-play", null, "조경찬", + "namejgc@naver.com", true, 5f)); + Member member2 = memberRepository.save( + new Member("ashsty", null, "박민아", + null, false, 1.5f)); + Member member3 = memberRepository.save( + new Member("youngsu5582", null, "이영수", + null, false, 4f)); + Member member4 = memberRepository.save( + new Member("hjk0761", null, "김현중", + null, true, 3f)); + Member member5 = memberRepository.save( + new Member("chlwlstlf", null, "최진실", + null, true, 2f)); + Member member6 = memberRepository.save( + new Member("00kang", null, "강다빈", + null, true, 1f)); + Member member7 = memberRepository.save( + new Member("pp449", null, "이상엽", + "mma7710@naver.com", true, 4.8f)); + + Room room1 = roomRepository.save( + new Room("방 제목 1", "방 설명 1", 3, + null, null, List.of("TDD", "클린코드"), + 1, 20, member1, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.BACKEND, RoomStatus.OPENED)); + Room room2 = roomRepository.save( + new Room("방 제목 2", "방 설명 2", 3, + null, null, List.of("TDD"), + 1, 20, member2, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.BACKEND, RoomStatus.OPENED)); + Room room3 = roomRepository.save( + new Room("방 제목 3", "방 설명 3", 3, + null, null, List.of("TDD"), + 1, 20, member3, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.ANDROID, RoomStatus.OPENED)); + Room room4 = roomRepository.save( + new Room("방 제목 4", "방 설명 4", 3, + null, null, List.of("TDD"), + 1, 20, member4, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.ANDROID, RoomStatus.OPENED)); + Room room5 = roomRepository.save( + new Room("방 제목 5", "방 설명 5", 3, + null, null, List.of("TDD"), + 1, 20, member5, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.FRONTEND, RoomStatus.OPENED)); + Room room6 = roomRepository.save( + new Room("방 제목 6", "방 설명 6", 3, + null, null, List.of("TDD"), + 1, 20, member6, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.FRONTEND, RoomStatus.OPENED)); + Room room7 = roomRepository.save( + new Room("방 제목 7", "방 설명 7", 3, + null, null, List.of("TDD"), + 1, 20, member7, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.FRONTEND, RoomStatus.OPENED)); + roomRepository.save( + new Room("방 제목 8", "방 설명 8", 3, + null, null, List.of("TDD", "클린코드"), + 1, 20, member1, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.BACKEND, RoomStatus.CLOSED)); + roomRepository.save( + new Room("방 제목 9", "방 설명 9", 3, + null, null, List.of("TDD"), + 1, 20, member3, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.ANDROID, RoomStatus.CLOSED)); + roomRepository.save( + new Room("방 제목 10", "방 설명 10", 3, + null, null, List.of("TDD"), + 1, 20, member5, + LocalDateTime.of(2024, 12, 25, 12, 0), + LocalDateTime.of(2024, 12, 30, 12, 0), + Classification.FRONTEND, RoomStatus.CLOSED)); + + participationRepository.save(new Participation(room1.getId(), member2.getId())); + participationRepository.save(new Participation(room1.getId(), member3.getId())); + + participationRepository.save(new Participation(room2.getId(), member3.getId())); + participationRepository.save(new Participation(room2.getId(), member4.getId())); + + participationRepository.save(new Participation(room3.getId(), member4.getId())); + participationRepository.save(new Participation(room3.getId(), member5.getId())); + + participationRepository.save(new Participation(room4.getId(), member5.getId())); + participationRepository.save(new Participation(room4.getId(), member6.getId())); + + participationRepository.save(new Participation(room5.getId(), member6.getId())); + participationRepository.save(new Participation(room5.getId(), member7.getId())); + + participationRepository.save(new Participation(room6.getId(), member1.getId())); + participationRepository.save(new Participation(room6.getId(), member7.getId())); + + participationRepository.save(new Participation(room7.getId(), member1.getId())); + participationRepository.save(new Participation(room7.getId(), member2.getId())); + } +} diff --git a/backend/src/main/java/corea/WebConfig.java b/backend/src/main/java/corea/WebConfig.java new file mode 100644 index 000000000..b3cf71fec --- /dev/null +++ b/backend/src/main/java/corea/WebConfig.java @@ -0,0 +1,34 @@ +package corea; + +import corea.auth.resolver.AccessedMemberArgumentResolver; +import corea.auth.resolver.LoginMemberArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AccessedMemberArgumentResolver accessedMemberArgumentResolver; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:8080", "http://localhost:8081") + .allowedMethods("GET", "POST", "DELETE") + .allowCredentials(true) + .maxAge(3000); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + resolvers.add(accessedMemberArgumentResolver); + } +} diff --git a/backend/src/main/java/corea/auth/RequestHandler.java b/backend/src/main/java/corea/auth/RequestHandler.java new file mode 100644 index 000000000..7f1b2139d --- /dev/null +++ b/backend/src/main/java/corea/auth/RequestHandler.java @@ -0,0 +1,14 @@ +package corea.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +@Component +public class RequestHandler { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + public String extract(HttpServletRequest request) { + return request.getHeader(AUTHORIZATION_HEADER); + } +} diff --git a/backend/src/main/java/corea/auth/annotation/AccessedMember.java b/backend/src/main/java/corea/auth/annotation/AccessedMember.java new file mode 100644 index 000000000..7de1ac1c5 --- /dev/null +++ b/backend/src/main/java/corea/auth/annotation/AccessedMember.java @@ -0,0 +1,14 @@ +package corea.auth.annotation; + +import io.swagger.v3.oas.annotations.Hidden; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Hidden() +public @interface AccessedMember { +} diff --git a/backend/src/main/java/corea/auth/annotation/LoginMember.java b/backend/src/main/java/corea/auth/annotation/LoginMember.java new file mode 100644 index 000000000..391917a06 --- /dev/null +++ b/backend/src/main/java/corea/auth/annotation/LoginMember.java @@ -0,0 +1,14 @@ +package corea.auth.annotation; + +import io.swagger.v3.oas.annotations.Hidden; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Hidden() +public @interface LoginMember { +} diff --git a/backend/src/main/java/corea/auth/domain/AuthInfo.java b/backend/src/main/java/corea/auth/domain/AuthInfo.java new file mode 100644 index 000000000..c758b6fda --- /dev/null +++ b/backend/src/main/java/corea/auth/domain/AuthInfo.java @@ -0,0 +1,26 @@ +package corea.auth.domain; + +import corea.member.domain.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class AuthInfo { + + private static final long NOT_EXIST_ID = -1L; + private static final String EMPTY_STRING = ""; + private static final AuthInfo ANONYMOUS = new AuthInfo(NOT_EXIST_ID, EMPTY_STRING, EMPTY_STRING); + + private final long id; + private final String name; + private final String email; + + public static AuthInfo from(Member member) { + return new AuthInfo(member.getId(), member.getUsername(), member.getEmail()); + } + + public static AuthInfo getAnonymous() { + return ANONYMOUS; + } +} diff --git a/backend/src/main/java/corea/auth/resolver/AccessedMemberArgumentResolver.java b/backend/src/main/java/corea/auth/resolver/AccessedMemberArgumentResolver.java new file mode 100644 index 000000000..f567ed57c --- /dev/null +++ b/backend/src/main/java/corea/auth/resolver/AccessedMemberArgumentResolver.java @@ -0,0 +1,37 @@ +package corea.auth.resolver; + +import corea.auth.RequestHandler; +import corea.auth.annotation.AccessedMember; +import corea.auth.domain.AuthInfo; +import corea.member.repository.MemberRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AccessedMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final RequestHandler requestHandler; + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AccessedMember.class); + } + + @Override + public AuthInfo resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + return memberRepository.findByEmail(requestHandler.extract(request)) + .map(AuthInfo::from) + .orElse(AuthInfo.getAnonymous()); + } +} diff --git a/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java b/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..8319b18e1 --- /dev/null +++ b/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java @@ -0,0 +1,40 @@ +package corea.auth.resolver; + +import corea.auth.RequestHandler; +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final RequestHandler requestHandler; + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class); + } + + @Override + public AuthInfo resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Member member = memberRepository.findByEmail(requestHandler.extract(request)) + .orElseThrow(()-> new CoreaException(ExceptionType.AUTHORIZATION_ERROR)); + + return AuthInfo.from(member); + } +} diff --git a/backend/src/main/java/corea/exception/CoreaException.java b/backend/src/main/java/corea/exception/CoreaException.java new file mode 100644 index 000000000..59ea6a438 --- /dev/null +++ b/backend/src/main/java/corea/exception/CoreaException.java @@ -0,0 +1,34 @@ +package corea.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CoreaException extends RuntimeException { + + private final ExceptionType exceptionType; + + public CoreaException(ExceptionType exceptionType) { + super(exceptionType.getMessage()); + this.exceptionType = exceptionType; + } + + public CoreaException(ExceptionType exceptionType, String message) { + super(message); + this.exceptionType = exceptionType; + } + + public CoreaException(ExceptionType exceptionType, Throwable cause) { + super(exceptionType.getMessage(), cause); + this.exceptionType = exceptionType; + } + + public HttpStatus getHttpStatus() { + return exceptionType.getHttpStatus(); + } + + @Override + public String getMessage() { + return super.getMessage(); + } +} diff --git a/backend/src/main/java/corea/exception/ErrorResponse.java b/backend/src/main/java/corea/exception/ErrorResponse.java new file mode 100644 index 000000000..a9fd7dac8 --- /dev/null +++ b/backend/src/main/java/corea/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package corea.exception; + +public record ErrorResponse(String message) { +} diff --git a/backend/src/main/java/corea/exception/ExceptionResponseHandler.java b/backend/src/main/java/corea/exception/ExceptionResponseHandler.java new file mode 100644 index 000000000..932382649 --- /dev/null +++ b/backend/src/main/java/corea/exception/ExceptionResponseHandler.java @@ -0,0 +1,25 @@ +package corea.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Slf4j +@ControllerAdvice +public class ExceptionResponseHandler { + + @ExceptionHandler(CoreaException.class) + public ResponseEntity handleCoreaException(final CoreaException e) { + log.debug("Corea exception [statusCode = {}, errorMessage = {}, cause = {}]", e.getHttpStatus(), e.getMessage(), e.getCause()); + return ResponseEntity.status(e.getHttpStatus()) + .body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(final Exception e) { + log.debug("Server exception [errorMessage = {}, cause = {}]", e.getMessage(), e.getCause()); + return ResponseEntity.internalServerError() + .body(new ErrorResponse(e.getMessage())); + } +} diff --git a/backend/src/main/java/corea/exception/ExceptionType.java b/backend/src/main/java/corea/exception/ExceptionType.java new file mode 100644 index 000000000..ca08e6750 --- /dev/null +++ b/backend/src/main/java/corea/exception/ExceptionType.java @@ -0,0 +1,32 @@ +package corea.exception; + +import org.springframework.http.HttpStatus; + +public enum ExceptionType { + + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 발생했습니다."), + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 멤버를 찾을 수 없습니다."), + ROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "방을 찾을 수 없습니다."), + NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "해당하는 값이 없습니다."), + AUTHORIZATION_ERROR(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), + ALREADY_APPLY(HttpStatus.BAD_REQUEST, "해당 방에 이미 참여했습니다"), + PARTICIPANT_SIZE_LACK(HttpStatus.BAD_REQUEST, "참여 인원 수가 부족합니다."), + NOT_MATCHED_MEMBER(HttpStatus.BAD_REQUEST, "매칭된 인원들이 아닙니다."), + ALREADY_COMPLETED_REVIEW(HttpStatus.BAD_REQUEST, "이미 리뷰를 완료했습니다."); + + private final HttpStatus httpStatus; + private final String message; + + ExceptionType(final HttpStatus httpStatus, final String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/corea/global/config/ControllerLoggingAspect.java b/backend/src/main/java/corea/global/config/ControllerLoggingAspect.java new file mode 100644 index 000000000..5c566f3c2 --- /dev/null +++ b/backend/src/main/java/corea/global/config/ControllerLoggingAspect.java @@ -0,0 +1,150 @@ +package corea.global.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +@Aspect +@Component +@Profile("!prod") +public final class ControllerLoggingAspect { + + @Pointcut(""" + @annotation(org.springframework.web.bind.annotation.RequestMapping) + || @annotation(org.springframework.web.bind.annotation.GetMapping) + || @annotation(org.springframework.web.bind.annotation.PostMapping) + || @annotation(org.springframework.web.bind.annotation.PatchMapping) + || @annotation(org.springframework.web.bind.annotation.PutMapping) + || @annotation(org.springframework.web.bind.annotation.DeleteMapping) + """) + void loggingPointcut() { + } + + private Optional getRequest() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(ServletRequestAttributes::getRequest); + } + + @Around("loggingPointcut()") + Object logAround(final ProceedingJoinPoint joinPoint) throws Throwable { + final var log = getLog(joinPoint); + loggingEnter(joinPoint, log); + return loggingReturn(joinPoint, log); + } + + private Object loggingReturn( + final ProceedingJoinPoint joinPoint, + final Logger log + ) throws Throwable { + final var startMillis = System.currentTimeMillis(); + final var result = joinPoint.proceed(); + final var elapsedMillis = System.currentTimeMillis() - startMillis; + final Consumer httpServletRequestLogger = request -> log.debug( + "return [time={}, url={}, httpMethod={}, ip={}, class={}, method={}, result={}, elapsedMillis={}]", + LocalDateTime.now(), + request.getRequestURL(), + request.getMethod(), + IpExtractor.extract(request), + joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName(), + result, + elapsedMillis + ); + final Runnable requestNotFoundLogger = () -> log.debug( + "return [time={}, class={}, method={}, result={}, elapsedMillis={}]", + LocalDateTime.now(), + joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName(), + result, + elapsedMillis + ); + getRequest().ifPresentOrElse(httpServletRequestLogger, requestNotFoundLogger); + + return result; + } + + private void loggingEnter(final ProceedingJoinPoint joinPoint, final Logger log) { + final Consumer httpServletRequestLogger = request -> log.debug( + "enter [time={}, url={}, httpMethod={}, ip={}, class={}, method={}, hashcode={}, arguments={}]", + LocalDateTime.now(), + request.getRequestURL(), + request.getMethod(), + IpExtractor.extract(request), + joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName(), + Thread.currentThread().hashCode(), + Arrays.toString(joinPoint.getArgs()) + ); + final Runnable requestNotFoundLogger = () -> log.warn( + "enter [time={}, class={}, method={}, hashcode={}, arguments={}]", + LocalDateTime.now(), + joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName(), + Thread.currentThread().hashCode(), + Arrays.toString(joinPoint.getArgs()) + ); + getRequest().ifPresentOrElse(httpServletRequestLogger, requestNotFoundLogger); + } + + private Logger getLog(final JoinPoint joinPoint) { + return LoggerFactory.getLogger(joinPoint.getTarget() + .getClass()); + } + + private enum IpExtractor { + + X_FORWARDED_FOR("X-Forwarded-For"), + PROXY_CLIENT_IP("Proxy-Client-IP"), + WL_PROXY_CLIENT_IP("WL-Proxy-Client-IP"), + HTTP_CLIENT_IP("HTTP_CLIENT_IP"), + HTTP_X_FORWARDED_FOR("HTTP_X_FORWARDED_FOR"), + X_REAL_IP("X-Real-IP"), + @SuppressWarnings("SpellCheckingInspection") X_REALIP("X-RealIP"), + REMOTE_ADDR("REMOTE_ADDR"), + REMOTE_ADDR_BUILD_IN(HttpServletRequest::getRemoteAddr), + + ; + + private static final String UNKNOWN = "unknown"; + + private final Function mapper; + + IpExtractor(final String headerName) { + this(request -> request.getHeader(headerName)); + } + + IpExtractor(final Function mapper) { + this.mapper = mapper; + } + + private static String extract(final HttpServletRequest request) { + return Arrays.stream(values()) + .map(it -> it.mapper.apply(request)) + .filter(IpExtractor::isValidIp) + .findAny() + .orElse(UNKNOWN); + } + + private static boolean isValidIp(final String ip) { + return StringUtils.hasText(ip) && !UNKNOWN.equalsIgnoreCase(ip); + } + } +} diff --git a/backend/src/main/java/corea/global/config/SwaggerConfig.java b/backend/src/main/java/corea/global/config/SwaggerConfig.java new file mode 100644 index 000000000..3031849be --- /dev/null +++ b/backend/src/main/java/corea/global/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package corea.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.OpenAPI; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + info = @Info( + title = "CoReA OpenAPI 문서", + description = "작성한 명세를 기반으로 문서화 되었습니다.", + version = "v1" + ) +) +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI(); + } +} diff --git a/backend/src/main/java/corea/matching/controller/ParticipateController.java b/backend/src/main/java/corea/matching/controller/ParticipateController.java new file mode 100644 index 000000000..5648998d2 --- /dev/null +++ b/backend/src/main/java/corea/matching/controller/ParticipateController.java @@ -0,0 +1,27 @@ +package corea.matching.controller; + +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.matching.dto.ParticipationRequest; +import corea.matching.service.ParticipationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/participate") +@RequiredArgsConstructor +public class ParticipateController implements ParticipationControllerSpecification { + + private final ParticipationService participationService; + + @PostMapping("/{id}") + public ResponseEntity participate(@PathVariable long id, @LoginMember AuthInfo authInfo) { + participationService.participate(new ParticipationRequest(id, authInfo.getId())); + return ResponseEntity.ok() + .build(); + } +} diff --git a/backend/src/main/java/corea/matching/controller/ParticipationControllerSpecification.java b/backend/src/main/java/corea/matching/controller/ParticipationControllerSpecification.java new file mode 100644 index 000000000..be9d56a41 --- /dev/null +++ b/backend/src/main/java/corea/matching/controller/ParticipationControllerSpecification.java @@ -0,0 +1,24 @@ +package corea.matching.controller; + +import corea.auth.domain.AuthInfo; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; + +public interface ParticipationControllerSpecification { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당하는 방이 없는 경우", value = """ + { + "message": "1에 해당하는 방 없습니다." + } + """) + })), + } + ) + ResponseEntity participate(long id, AuthInfo authInfo); +} diff --git a/backend/src/main/java/corea/matching/domain/MatchResult.java b/backend/src/main/java/corea/matching/domain/MatchResult.java new file mode 100644 index 000000000..58551b858 --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/MatchResult.java @@ -0,0 +1,44 @@ +package corea.matching.domain; + +import corea.member.domain.Member; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@Getter +public class MatchResult { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private long roomId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_member_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Member reviewer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "to_member_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Member reviewee; + + private String prLink; + + @Enumerated(EnumType.STRING) + private ReviewStatus reviewStatus; + + public MatchResult(long roomId, Member reviewer, Member reviewee, String prLink) { + this(null, roomId, reviewer, reviewee, prLink, ReviewStatus.INCOMPLETE); + } + + public void reviewComplete() { + reviewStatus = ReviewStatus.COMPLETE; + } +} diff --git a/backend/src/main/java/corea/matching/domain/MatchingStrategy.java b/backend/src/main/java/corea/matching/domain/MatchingStrategy.java new file mode 100644 index 000000000..fd32d592e --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/MatchingStrategy.java @@ -0,0 +1,8 @@ +package corea.matching.domain; + +import java.util.List; + +public interface MatchingStrategy { + + List matchPairs(List memberIds, int matchingSize); +} diff --git a/backend/src/main/java/corea/matching/domain/Pair.java b/backend/src/main/java/corea/matching/domain/Pair.java new file mode 100644 index 000000000..e63c5b558 --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/Pair.java @@ -0,0 +1,12 @@ +package corea.matching.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class Pair { + + private final Long fromMemberId; + private final Long toMemberId; +} diff --git a/backend/src/main/java/corea/matching/domain/Participation.java b/backend/src/main/java/corea/matching/domain/Participation.java new file mode 100644 index 000000000..5569a4e00 --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/Participation.java @@ -0,0 +1,29 @@ +package corea.matching.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Participation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private long roomId; + + private long memberId; + + public Participation(final long roomId, final long memberId) { + this(null, roomId, memberId); + } +} diff --git a/backend/src/main/java/corea/matching/domain/PlainRandomMatching.java b/backend/src/main/java/corea/matching/domain/PlainRandomMatching.java new file mode 100644 index 000000000..9c8ca8512 --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/PlainRandomMatching.java @@ -0,0 +1,29 @@ +package corea.matching.domain; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Component +public class PlainRandomMatching implements MatchingStrategy { + + @Override + public List matchPairs(List memberIds, int matchingSize) { + List shuffledMemberIds = new ArrayList<>(memberIds); + Collections.shuffle(shuffledMemberIds); + + return match(shuffledMemberIds, matchingSize); + } + + private List match(List shuffledMemberIds, int matchingSize) { + List reviewerResult = new ArrayList<>(); + for (int i = 0; i < shuffledMemberIds.size(); i++) { + for (int j = 1; j <= matchingSize; j++) { + reviewerResult.add(new Pair(shuffledMemberIds.get(i), shuffledMemberIds.get((i + j) % shuffledMemberIds.size()))); + } + } + return reviewerResult; + } +} diff --git a/backend/src/main/java/corea/matching/domain/ReviewStatus.java b/backend/src/main/java/corea/matching/domain/ReviewStatus.java new file mode 100644 index 000000000..ca44cd3e4 --- /dev/null +++ b/backend/src/main/java/corea/matching/domain/ReviewStatus.java @@ -0,0 +1,6 @@ +package corea.matching.domain; + +public enum ReviewStatus { + + COMPLETE, INCOMPLETE +} diff --git a/backend/src/main/java/corea/matching/dto/MatchResultResponse.java b/backend/src/main/java/corea/matching/dto/MatchResultResponse.java new file mode 100644 index 000000000..61490c561 --- /dev/null +++ b/backend/src/main/java/corea/matching/dto/MatchResultResponse.java @@ -0,0 +1,12 @@ +package corea.matching.dto; + +import corea.matching.domain.MatchResult; +import corea.matching.domain.ReviewStatus; +import corea.member.domain.Member; + +public record MatchResultResponse(long userId, String username, String link, ReviewStatus isReviewed) { + + public static MatchResultResponse of(MatchResult matchResult, Member member) { + return new MatchResultResponse(member.getId(), member.getUsername(), matchResult.getPrLink(), matchResult.getReviewStatus()); + } +} diff --git a/backend/src/main/java/corea/matching/dto/MatchResultResponses.java b/backend/src/main/java/corea/matching/dto/MatchResultResponses.java new file mode 100644 index 000000000..ba959f5fa --- /dev/null +++ b/backend/src/main/java/corea/matching/dto/MatchResultResponses.java @@ -0,0 +1,6 @@ +package corea.matching.dto; + +import java.util.List; + +public record MatchResultResponses(List matchResultResponses) { +} diff --git a/backend/src/main/java/corea/matching/dto/ParticipationRequest.java b/backend/src/main/java/corea/matching/dto/ParticipationRequest.java new file mode 100644 index 000000000..03a351f41 --- /dev/null +++ b/backend/src/main/java/corea/matching/dto/ParticipationRequest.java @@ -0,0 +1,10 @@ +package corea.matching.dto; + +import corea.matching.domain.Participation; + +public record ParticipationRequest(long roomId, long memberId) { + + public Participation toEntity() { + return new Participation(roomId, memberId); + } +} diff --git a/backend/src/main/java/corea/matching/dto/ParticipationResponse.java b/backend/src/main/java/corea/matching/dto/ParticipationResponse.java new file mode 100644 index 000000000..a716d3f7b --- /dev/null +++ b/backend/src/main/java/corea/matching/dto/ParticipationResponse.java @@ -0,0 +1,10 @@ +package corea.matching.dto; + +import corea.matching.domain.Participation; + +public record ParticipationResponse(long id, long roomId, long memberId) { + + public static ParticipationResponse from(final Participation participation) { + return new ParticipationResponse(participation.getId(), participation.getRoomId(), participation.getMemberId()); + } +} diff --git a/backend/src/main/java/corea/matching/repository/MatchResultRepository.java b/backend/src/main/java/corea/matching/repository/MatchResultRepository.java new file mode 100644 index 000000000..9eb2db67b --- /dev/null +++ b/backend/src/main/java/corea/matching/repository/MatchResultRepository.java @@ -0,0 +1,16 @@ +package corea.matching.repository; + +import corea.matching.domain.MatchResult; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MatchResultRepository extends JpaRepository { + + List findAllByRevieweeIdAndRoomId(long revieweeId, long roomId); + + List findAllByReviewerIdAndRoomId(long reviewerId, long roomId); + + Optional findByRoomIdAndReviewerIdAndRevieweeId(long roomId, long reviewerId, long revieweeId); +} diff --git a/backend/src/main/java/corea/matching/repository/ParticipationRepository.java b/backend/src/main/java/corea/matching/repository/ParticipationRepository.java new file mode 100644 index 000000000..e919faeba --- /dev/null +++ b/backend/src/main/java/corea/matching/repository/ParticipationRepository.java @@ -0,0 +1,15 @@ +package corea.matching.repository; + +import corea.matching.domain.Participation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ParticipationRepository extends JpaRepository { + + List findAllByRoomId(long roomId); + + List findAllByMemberId(long memberId); + + boolean existsByRoomIdAndMemberId(long roomId, long memberId); +} diff --git a/backend/src/main/java/corea/matching/service/MatchResultService.java b/backend/src/main/java/corea/matching/service/MatchResultService.java new file mode 100644 index 000000000..c9d962b51 --- /dev/null +++ b/backend/src/main/java/corea/matching/service/MatchResultService.java @@ -0,0 +1,52 @@ +package corea.matching.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.MatchResult; +import corea.matching.dto.MatchResultResponse; +import corea.matching.dto.MatchResultResponses; +import corea.matching.repository.MatchResultRepository; +import corea.member.repository.MemberRepository; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MatchResultService { + + private final MemberRepository memberRepository; + private final RoomRepository roomRepository; + private final MatchResultRepository matchResultRepository; + + public MatchResultResponses findReviewers(long memberId, long roomId) { + validateExistence(memberId, roomId); + List results = matchResultRepository.findAllByRevieweeIdAndRoomId(memberId, roomId); + + return new MatchResultResponses(results.stream() + .map(result -> MatchResultResponse.of(result, result.getReviewer())) + .toList()); + } + + public MatchResultResponses findReviewees(long memberId, long roomId) { + validateExistence(memberId, roomId); + List results = matchResultRepository.findAllByReviewerIdAndRoomId(memberId, roomId); + + return new MatchResultResponses(results.stream() + .map(result -> MatchResultResponse.of(result, result.getReviewee())) + .toList()); + } + + private void validateExistence(long memberId, long roomId) { + if (!memberRepository.existsById(memberId)) { + throw new CoreaException(ExceptionType.MEMBER_NOT_FOUND, String.format("%d에 해당하는 멤버가 없습니다.", memberId)); + } + if (!roomRepository.existsById(roomId)) { + throw new CoreaException(ExceptionType.ROOM_NOT_FOUND, String.format("%d에 해당하는 방이 없습니다.", roomId)); + } + } +} diff --git a/backend/src/main/java/corea/matching/service/MatchingService.java b/backend/src/main/java/corea/matching/service/MatchingService.java new file mode 100644 index 000000000..09e87c5cb --- /dev/null +++ b/backend/src/main/java/corea/matching/service/MatchingService.java @@ -0,0 +1,55 @@ +package corea.matching.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.MatchResult; +import corea.matching.domain.Pair; +import corea.matching.domain.Participation; +import corea.matching.domain.PlainRandomMatching; +import corea.matching.repository.MatchResultRepository; +import corea.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MatchingService { + + private final PlainRandomMatching plainRandomMatching; + private final MatchResultRepository matchResultRepository; + private final MemberRepository memberRepository; + + public void matchMaking(List participations, int matchingSize) { + validateParticipationSize(participations, matchingSize); + List memberIds = participations.stream() + .map(Participation::getMemberId) + .toList(); + + long roomId = participations.get(0).getRoomId(); + + List results = plainRandomMatching.matchPairs(memberIds, matchingSize); + + results.stream() + .map(pair -> new MatchResult( + roomId, + memberRepository.findById(pair.getFromMemberId()).orElseThrow( + () -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND, String.format("%d에 해당하는 멤버가 없습니다.", pair.getFromMemberId())) + ), + memberRepository.findById(pair.getToMemberId()).orElseThrow( + () -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND, String.format("%d에 해당하는 멤버가 없습니다.", pair.getToMemberId())) + ), + null)) + //TODO: prLink 차후 수정 + .forEach(matchResultRepository::save); + } + + private void validateParticipationSize(List participations, int matchingSize) { + if (participations.size() <= matchingSize) { + throw new CoreaException(ExceptionType.PARTICIPANT_SIZE_LACK); + } + } +} diff --git a/backend/src/main/java/corea/matching/service/ParticipationService.java b/backend/src/main/java/corea/matching/service/ParticipationService.java new file mode 100644 index 000000000..026d4c2d6 --- /dev/null +++ b/backend/src/main/java/corea/matching/service/ParticipationService.java @@ -0,0 +1,42 @@ +package corea.matching.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.Participation; +import corea.matching.dto.ParticipationRequest; +import corea.matching.dto.ParticipationResponse; +import corea.matching.repository.ParticipationRepository; +import corea.member.repository.MemberRepository; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ParticipationService { + + private final ParticipationRepository participationRepository; + private final RoomRepository roomRepository; + private final MemberRepository memberRepository; + + public ParticipationResponse participate(final ParticipationRequest request) { + validateIdExist(request.roomId(), request.memberId()); + + final Participation participation = participationRepository.save(request.toEntity()); + return ParticipationResponse.from(participation); + } + + private void validateIdExist(final long roomId, final long memberId) { + if (!roomRepository.existsById(roomId)) { + throw new CoreaException(ExceptionType.NOT_FOUND_ERROR, String.format("%d에 해당하는 방이 없습니다.", roomId)); + } + if (!memberRepository.existsById(memberId)) { + throw new CoreaException(ExceptionType.NOT_FOUND_ERROR, String.format("%d에 해당하는 멤버가 없습니다.", memberId)); + } + if (participationRepository.existsByRoomIdAndMemberId(roomId, memberId)) { + throw new CoreaException(ExceptionType.ALREADY_APPLY); + } + } +} diff --git a/backend/src/main/java/corea/member/domain/Member.java b/backend/src/main/java/corea/member/domain/Member.java new file mode 100644 index 000000000..56bf6e730 --- /dev/null +++ b/backend/src/main/java/corea/member/domain/Member.java @@ -0,0 +1,37 @@ +package corea.member.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.*; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Member { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String username; + + private String thumbnailUrl; + + private String name; + + private String email; + + private boolean isEmailAccepted; + + private float attitude; + + private String profileLink; + + public Member(String username, String thumbnailUrl, String name, String email, boolean isEmailAccepted, float attitude) { + this(null, username, thumbnailUrl, name, email, isEmailAccepted, attitude, null); + } +} diff --git a/backend/src/main/java/corea/member/repository/MemberRepository.java b/backend/src/main/java/corea/member/repository/MemberRepository.java new file mode 100644 index 000000000..9c1620b2c --- /dev/null +++ b/backend/src/main/java/corea/member/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package corea.member.repository; + +import corea.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/corea/review/controller/ReviewController.java b/backend/src/main/java/corea/review/controller/ReviewController.java new file mode 100644 index 000000000..6dc2635c7 --- /dev/null +++ b/backend/src/main/java/corea/review/controller/ReviewController.java @@ -0,0 +1,27 @@ +package corea.review.controller; + +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.review.dto.ReviewRequest; +import corea.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/review") +@RequiredArgsConstructor +public class ReviewController implements ReviewControllerSpecification { + + private final ReviewService reviewService; + + @PostMapping("/complete") + public ResponseEntity complete(@LoginMember AuthInfo authInfo, @RequestBody ReviewRequest request) { + reviewService.review(request.roomId(), authInfo.getId(), request.revieweeId()); + return ResponseEntity.ok() + .build(); + } +} diff --git a/backend/src/main/java/corea/review/controller/ReviewControllerSpecification.java b/backend/src/main/java/corea/review/controller/ReviewControllerSpecification.java new file mode 100644 index 000000000..b1656b780 --- /dev/null +++ b/backend/src/main/java/corea/review/controller/ReviewControllerSpecification.java @@ -0,0 +1,10 @@ +package corea.review.controller; + +import corea.auth.domain.AuthInfo; +import corea.review.dto.ReviewRequest; +import org.springframework.http.ResponseEntity; + +public interface ReviewControllerSpecification { + + ResponseEntity complete(AuthInfo authInfo, ReviewRequest request); +} diff --git a/backend/src/main/java/corea/review/dto/ReviewRequest.java b/backend/src/main/java/corea/review/dto/ReviewRequest.java new file mode 100644 index 000000000..f7c719257 --- /dev/null +++ b/backend/src/main/java/corea/review/dto/ReviewRequest.java @@ -0,0 +1,4 @@ +package corea.review.dto; + +public record ReviewRequest(long roomId, long revieweeId) { +} diff --git a/backend/src/main/java/corea/review/service/ReviewService.java b/backend/src/main/java/corea/review/service/ReviewService.java new file mode 100644 index 000000000..5b80efd96 --- /dev/null +++ b/backend/src/main/java/corea/review/service/ReviewService.java @@ -0,0 +1,29 @@ +package corea.review.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.MatchResult; +import corea.matching.repository.MatchResultRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private final MatchResultRepository matchResultRepository; + + @Transactional + public void review(long roomId, long reviewerId, long revieweeId) { + MatchResult matchResult = getMatchResult(roomId, reviewerId, revieweeId); + matchResult.reviewComplete(); + } + + private MatchResult getMatchResult(long roomId, long reviewerId, long revieweeId) { + return matchResultRepository.findByRoomIdAndReviewerIdAndRevieweeId(roomId, reviewerId, revieweeId) + .orElseThrow(() -> new CoreaException(ExceptionType.NOT_MATCHED_MEMBER, + String.format("%d와 %d는 방 %d에서 매칭된 멤버가 아닙니다.", reviewerId, revieweeId, roomId))); + } +} diff --git a/backend/src/main/java/corea/room/controller/RoomController.java b/backend/src/main/java/corea/room/controller/RoomController.java new file mode 100644 index 000000000..e4165ca00 --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomController.java @@ -0,0 +1,61 @@ +package corea.room.controller; + +import corea.auth.annotation.AccessedMember; +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.matching.dto.MatchResultResponses; +import corea.matching.service.MatchResultService; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.service.RoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/rooms") +@RequiredArgsConstructor +public class RoomController implements RoomControllerSpecification { + + private final RoomService roomService; + private final MatchResultService matchResultService; + + @GetMapping("/{id}") + public ResponseEntity room(@PathVariable long id, @AccessedMember AuthInfo authInfo) { + RoomResponse response = roomService.findOne(id, authInfo.getId()); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/reviewers") + public ResponseEntity reviewers(@PathVariable long id, @LoginMember AuthInfo authInfo) { + MatchResultResponses response = matchResultService.findReviewers(authInfo.getId(), id); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/reviewees") + public ResponseEntity reviewees(@PathVariable long id, @LoginMember AuthInfo authInfo) { + MatchResultResponses response = matchResultService.findReviewees(authInfo.getId(), id); + return ResponseEntity.ok(response); + } + + @GetMapping("/participated") + public ResponseEntity participatedRooms(@LoginMember AuthInfo authInfo) { + RoomResponses response = roomService.findParticipatedRooms(authInfo.getId()); + return ResponseEntity.ok(response); + } + + @GetMapping("/opened") + public ResponseEntity openedRooms(@AccessedMember AuthInfo authInfo, + @RequestParam(value = "classification", defaultValue = "all") String expression, + @RequestParam(defaultValue = "0") int page) { + RoomResponses response = roomService.findOpenedRooms(authInfo.getId(), expression, page); + return ResponseEntity.ok(response); + } + + @GetMapping("/closed") + public ResponseEntity closedRooms(@RequestParam(value = "classification", defaultValue = "all") String expression, + @RequestParam(defaultValue = "0") int page) { + RoomResponses response = roomService.findClosedRooms(expression, page); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java b/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java new file mode 100644 index 000000000..c45eaf2c9 --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java @@ -0,0 +1,46 @@ +package corea.room.controller; + +import corea.auth.domain.AuthInfo; +import corea.matching.dto.MatchResultResponses; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; + +public interface RoomControllerSpecification { + + ResponseEntity room(long id, AuthInfo authInfo); + + ResponseEntity participatedRooms(AuthInfo authInfo); + + ResponseEntity openedRooms(AuthInfo authInfo, String expression, int page); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "리뷰어를 조회할 수 없는 경우", value = """ + { + "message": "리뷰어 정보를 찾을 수 없습니다." + } + """) + })), + } + ) + ResponseEntity reviewers(long id, AuthInfo authInfo); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "리뷰이를 조회할 수 없는 경우", value = """ + { + "message": "리뷰이 정보를 찾을 수 없습니다." + } + """) + })), + } + ) + ResponseEntity reviewees(long id, AuthInfo authInfo); +} diff --git a/backend/src/main/java/corea/room/domain/Classification.java b/backend/src/main/java/corea/room/domain/Classification.java new file mode 100644 index 000000000..7f0a545a2 --- /dev/null +++ b/backend/src/main/java/corea/room/domain/Classification.java @@ -0,0 +1,38 @@ +package corea.room.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; + +import static java.util.stream.Collectors.toMap; + +public enum Classification { + + ALL("all"), + ANDROID("an"), + BACKEND("be"), + FRONTEND("fe"); + + private static final Map CACHED_CLASSIFICATIONS = Arrays.stream(values()) + .collect(toMap(classification -> classification.expression, Function.identity())); + + private final String expression; + + Classification(String expression) { + this.expression = expression; + } + + public static Classification from(String expression) { + if (CACHED_CLASSIFICATIONS.containsKey(expression)) { + return CACHED_CLASSIFICATIONS.get(expression); + } + throw new CoreaException(ExceptionType.NOT_FOUND_ERROR); + } + + public boolean isAll() { + return this == ALL; + } +} diff --git a/backend/src/main/java/corea/room/domain/Room.java b/backend/src/main/java/corea/room/domain/Room.java new file mode 100644 index 000000000..d7ed52989 --- /dev/null +++ b/backend/src/main/java/corea/room/domain/Room.java @@ -0,0 +1,79 @@ +package corea.room.domain; + +import corea.member.domain.Member; +import corea.util.StringToListConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Room { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String title; + + private String content; + + private int matchingSize; + + @Column(length = 32768) + private String repositoryLink; + + @Column(length = 32768) + private String thumbnailLink; + + @Convert(converter = StringToListConverter.class) + private List keyword; + + private int currentParticipantsSize; + + private int limitedParticipantsSize; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "manager_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Member manager; + + private LocalDateTime recruitmentDeadline; + + private LocalDateTime reviewDeadline; + + @Enumerated(value = EnumType.STRING) + private Classification classification; + + /** + * RoomStatus가 변경될 수 있는 경우 (OPENED -> CLOSED) + * 1. 방장이 모집 마감을 한 경우 + * 2. 제한 인원이 다 찼을 경우 (방에 참여할 때 같이 검증) + * 3. 모집 기간이 끝난 경우 + *

+ * 1, 2의 경우 때문에 방 상태를 가지는 필드를 가져야 될듯. + **/ + @Enumerated(value = EnumType.STRING) + private RoomStatus status; + + public Room(String title, String content, int matchingSize, String repositoryLink, String thumbnailLink, List keyword, int currentParticipantsSize, int limitedParticipantsSize, Member manager, LocalDateTime recruitmentDeadline, LocalDateTime reviewDeadline, Classification classification, RoomStatus status) { + this(null, title, content, matchingSize, repositoryLink, thumbnailLink, keyword, currentParticipantsSize, limitedParticipantsSize, manager, recruitmentDeadline, reviewDeadline, classification, status); + } + + public boolean isClosed() { + return status.isClosed(); + } + + public String getManagerName() { + return manager.getName(); + } +} + diff --git a/backend/src/main/java/corea/room/domain/RoomStatus.java b/backend/src/main/java/corea/room/domain/RoomStatus.java new file mode 100644 index 000000000..9409e085e --- /dev/null +++ b/backend/src/main/java/corea/room/domain/RoomStatus.java @@ -0,0 +1,14 @@ +package corea.room.domain; + +public enum RoomStatus { + + OPENED, CLOSED; + + public boolean isOpen() { + return this == OPENED; + } + + public boolean isClosed() { + return this == CLOSED; + } +} diff --git a/backend/src/main/java/corea/room/dto/RoomCreateRequest.java b/backend/src/main/java/corea/room/dto/RoomCreateRequest.java new file mode 100644 index 000000000..3e9a3dd9d --- /dev/null +++ b/backend/src/main/java/corea/room/dto/RoomCreateRequest.java @@ -0,0 +1,34 @@ +package corea.room.dto; + +import corea.member.domain.Member; +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record RoomCreateRequest( + String title, + String content, + Member manager, + String repositoryLink, + String thumbnailLink, + int matchingSize, + List keyword, + int currentParticipantsSize, + int limitedParticipantsSize, + LocalDateTime recruitmentDeadline, + LocalDateTime reviewDeadline, + Classification classification, + RoomStatus status +) { + + public Room toEntity() { + return new Room(title, content, matchingSize, + repositoryLink, thumbnailLink, keyword, + currentParticipantsSize, limitedParticipantsSize, manager, + recruitmentDeadline, reviewDeadline, classification, + status); + } +} diff --git a/backend/src/main/java/corea/room/dto/RoomResponse.java b/backend/src/main/java/corea/room/dto/RoomResponse.java new file mode 100644 index 000000000..21e514661 --- /dev/null +++ b/backend/src/main/java/corea/room/dto/RoomResponse.java @@ -0,0 +1,43 @@ +package corea.room.dto; + +import corea.room.domain.Room; + +import java.time.LocalDateTime; +import java.util.List; + +public record RoomResponse( + long id, + String title, + String content, + String author, + String repositoryLink, + String thumbnailLink, + int matchingSize, + List keywords, + long currentParticipants, + long limitedParticipants, + LocalDateTime recruitmentDeadline, + LocalDateTime reviewDeadline, + boolean isParticipated, + boolean isClosed +) { + + public static RoomResponse of(Room room, boolean isParticipated) { + return new RoomResponse( + room.getId(), + room.getTitle(), + room.getContent(), + room.getManagerName(), + room.getRepositoryLink(), + room.getThumbnailLink(), + room.getMatchingSize(), + room.getKeyword(), + room.getCurrentParticipantsSize(), + room.getLimitedParticipantsSize(), + room.getRecruitmentDeadline(), + room.getReviewDeadline(), + isParticipated, + room.isClosed() + ); + } +} diff --git a/backend/src/main/java/corea/room/dto/RoomResponses.java b/backend/src/main/java/corea/room/dto/RoomResponses.java new file mode 100644 index 000000000..5eacc1428 --- /dev/null +++ b/backend/src/main/java/corea/room/dto/RoomResponses.java @@ -0,0 +1,22 @@ +package corea.room.dto; + +import corea.room.domain.Room; +import org.springframework.data.domain.Page; + +import java.util.List; + +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toList; + +public record RoomResponses(List rooms) { + + public static RoomResponses of(List rooms, boolean isParticipated) { + return rooms.stream() + .map(room -> RoomResponse.of(room, isParticipated)) + .collect(collectingAndThen(toList(), RoomResponses::new)); + } + + public static RoomResponses from(Page roomsWithPage) { + return of(roomsWithPage.getContent(), false); + } +} diff --git a/backend/src/main/java/corea/room/repository/RoomRepository.java b/backend/src/main/java/corea/room/repository/RoomRepository.java new file mode 100644 index 000000000..2f177839b --- /dev/null +++ b/backend/src/main/java/corea/room/repository/RoomRepository.java @@ -0,0 +1,33 @@ +package corea.room.repository; + +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface RoomRepository extends JpaRepository { + + @Query(""" + SELECT r FROM Room r + LEFT JOIN Participation p + ON r.id = p.roomId AND p.memberId = :memberId + WHERE p.id IS NULL AND r.status = :status AND r.manager.id <> :memberId + """) + Page findAllByMemberAndStatus(long memberId, RoomStatus status, PageRequest pageRequest); + + @Query(""" + SELECT r FROM Room r + LEFT JOIN Participation p + ON r.id = p.roomId AND p.memberId = :memberId + WHERE p.id IS NULL AND r.classification = :classification AND r.status = :status AND r.manager.id <> :memberId + """) + Page findAllByMemberAndClassificationAndStatus(long memberId, Classification classification, RoomStatus status, Pageable pageable); + + Page findAllByStatus(RoomStatus status, PageRequest pageRequest); + + Page findAllByClassificationAndStatus(Classification classification, RoomStatus status, Pageable pageable); +} diff --git a/backend/src/main/java/corea/room/service/RoomService.java b/backend/src/main/java/corea/room/service/RoomService.java new file mode 100644 index 000000000..b9c7d5e25 --- /dev/null +++ b/backend/src/main/java/corea/room/service/RoomService.java @@ -0,0 +1,86 @@ +package corea.room.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.Participation; +import corea.matching.repository.ParticipationRepository; +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import corea.room.dto.RoomCreateRequest; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toList; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomService { + + private static final int PAGE_SIZE = 8; + + private final RoomRepository roomRepository; + private final ParticipationRepository participationRepository; + + public RoomResponse create(RoomCreateRequest request) { + Room room = roomRepository.save(request.toEntity()); + return RoomResponse.of(room, true); + } + + public RoomResponse findOne(long roomId, long memberId) { + Room room = getRoom(roomId); + boolean isParticipated = participationRepository.existsByRoomIdAndMemberId(roomId, memberId); + + return RoomResponse.of(room, isParticipated); + } + + public RoomResponses findParticipatedRooms(long memberId) { + List participations = participationRepository.findAllByMemberId(memberId); + + return participations.stream() + .map(Participation::getRoomId) + .map(this::getRoom) + .collect(collectingAndThen(toList(), rooms -> RoomResponses.of(rooms, true))); + } + + public RoomResponses findOpenedRooms(long memberId, String expression, int pageNumber) { + Classification classification = Classification.from(expression); + RoomStatus status = RoomStatus.OPENED; + PageRequest pageRequest = PageRequest.of(pageNumber, PAGE_SIZE); + + if (classification.isAll()) { + Page roomsWithPage = roomRepository.findAllByMemberAndStatus(memberId, status, pageRequest); + return RoomResponses.from(roomsWithPage); + } + Page roomsWithPage = roomRepository.findAllByMemberAndClassificationAndStatus(memberId, classification, status, pageRequest); + return RoomResponses.from(roomsWithPage); + } + + public RoomResponses findClosedRooms(String expression, int pageNumber) { + Classification classification = Classification.from(expression); + RoomStatus status = RoomStatus.CLOSED; + PageRequest pageRequest = PageRequest.of(pageNumber, PAGE_SIZE); + + if (classification.isAll()) { + Page roomsWithPage = roomRepository.findAllByStatus(status, pageRequest); + return RoomResponses.from(roomsWithPage); + } + Page roomsWithPage = roomRepository.findAllByClassificationAndStatus(classification, status, pageRequest); + return RoomResponses.from(roomsWithPage); + } + + private Room getRoom(long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND, String.format("해당 Id의 방이 없습니다. 입력된 Id=%d", roomId))); + } +} diff --git a/backend/src/main/java/corea/util/StringToListConverter.java b/backend/src/main/java/corea/util/StringToListConverter.java new file mode 100644 index 000000000..e75b4896e --- /dev/null +++ b/backend/src/main/java/corea/util/StringToListConverter.java @@ -0,0 +1,24 @@ +package corea.util; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Arrays; +import java.util.List; + +@Converter +public class StringToListConverter implements AttributeConverter, String> { + + private static final String DELIMITER = ", "; + + @Override + public String convertToDatabaseColumn(List source) { + return String.join(DELIMITER, source); + } + + @Override + public List convertToEntityAttribute(String source) { + return Arrays.stream(source.split(DELIMITER)) + .toList(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties deleted file mode 100644 index 3ca17a4e3..000000000 --- a/backend/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=backend diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..2d0986a9f --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,41 @@ +spring: + application: + name: backend + jpa: + defer-datasource-initialization: true + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create-drop + h2: + console: + enabled: true + path: /h2-console + datasource: + url: jdbc:h2:mem:database + +logging: + level: + root: INFO + corea: DEBUG + +springdoc: + swagger-ui: + groups-order: DESC + tags-sorter: alpha + operations-sorter: method + disable-swagger-default-url: true + display-request-duration: true + defaultModelsExpandDepth: 2 + defaultModelExpandDepth: 2 + api-docs: + path: /api-docs + show-actuator: true + default-consumes-media-type: application/json + default-produces-media-type: application/json + writer-with-default-pretty-printer: true + model-and-view-allowed: true + paths-to-match: + - /** diff --git a/backend/src/test/java/config/ControllerTest.java b/backend/src/test/java/config/ControllerTest.java new file mode 100644 index 000000000..c49f5e9b6 --- /dev/null +++ b/backend/src/test/java/config/ControllerTest.java @@ -0,0 +1,18 @@ +package config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.jdbc.Sql; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Sql(value = {"/clear.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestExecutionListeners( + value = {TestExecutionListener.class}, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public @interface ControllerTest { +} diff --git a/backend/src/test/java/config/ServiceTest.java b/backend/src/test/java/config/ServiceTest.java new file mode 100644 index 000000000..153cc44d1 --- /dev/null +++ b/backend/src/test/java/config/ServiceTest.java @@ -0,0 +1,13 @@ +package config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Sql(value = {"/clear.sql"},executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +public @interface ServiceTest { +} diff --git a/backend/src/test/java/config/TestExecutionListener.java b/backend/src/test/java/config/TestExecutionListener.java new file mode 100644 index 000000000..3ebbfd619 --- /dev/null +++ b/backend/src/test/java/config/TestExecutionListener.java @@ -0,0 +1,30 @@ +package config; + +import io.restassured.RestAssured; +import io.restassured.config.EncoderConfig; +import io.restassured.config.LogConfig; +import io.restassured.filter.log.LogDetail; +import io.restassured.http.ContentType; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +public class TestExecutionListener extends AbstractTestExecutionListener { + + @Override + public void beforeTestClass(final TestContext testContext) { + RestAssured.port = Optional.ofNullable(testContext.getApplicationContext() + .getEnvironment() + .getProperty("local.server.port", Integer.class)) + .orElseThrow(() -> new IllegalStateException("localServerPort는 null일 수 없습니다.")); + + RestAssured.config = RestAssured.config() + .logConfig(LogConfig.logConfig() + .enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL) + .enablePrettyPrinting(true)) + .encoderConfig(EncoderConfig.encoderConfig() + .defaultCharsetForContentType(StandardCharsets.UTF_8.name(), ContentType.ANY)); + } +} diff --git a/backend/src/test/java/corea/backend/BackendApplicationTests.java b/backend/src/test/java/corea/BackendApplicationTests.java similarity index 89% rename from backend/src/test/java/corea/backend/BackendApplicationTests.java rename to backend/src/test/java/corea/BackendApplicationTests.java index 20f54ffd4..11c44b064 100644 --- a/backend/src/test/java/corea/backend/BackendApplicationTests.java +++ b/backend/src/test/java/corea/BackendApplicationTests.java @@ -1,4 +1,4 @@ -package corea.backend; +package corea; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/backend/src/test/java/corea/fixture/MatchResultFixture.java b/backend/src/test/java/corea/fixture/MatchResultFixture.java new file mode 100644 index 000000000..741562bd3 --- /dev/null +++ b/backend/src/test/java/corea/fixture/MatchResultFixture.java @@ -0,0 +1,12 @@ +package corea.fixture; + +import corea.matching.domain.MatchResult; +import corea.member.domain.Member; + +public class MatchResultFixture { + + public static MatchResult MATCH_RESULT_DOMAIN(long roomId, Member reviewer, Member reviewee) { + return new MatchResult(roomId, reviewer, reviewee, + "https://github.com/woowacourse-teams/2024-corea/pull/99"); + } +} diff --git a/backend/src/test/java/corea/fixture/MemberFixture.java b/backend/src/test/java/corea/fixture/MemberFixture.java new file mode 100644 index 000000000..cec23390b --- /dev/null +++ b/backend/src/test/java/corea/fixture/MemberFixture.java @@ -0,0 +1,39 @@ +package corea.fixture; + +import corea.member.domain.Member; + +public class MemberFixture { + + public static Member MEMBER_YOUNGSU() { + return new Member( + "youngsu5582", + "https://avatars.githubusercontent.com/u/98307410?v=4", + null, + "youngsu5582@gmail.com", + false, + 36.5f + ); + } + + public static Member MEMBER_PORORO() { + return new Member( + "pororo", + "https://avatars.githubusercontent.com/u/98307410?v=4", + null, + "jcoding-play@gmail.com", + false, + 36.5f + ); + } + + public static Member MEMBER_JOYSON() { + return new Member( + "joyson5582", + "https://avatars.githubusercontent.com/u/98307410?v=4", + null, + "joyson5582@gmail.com", + false, + 36.5f + ); + } +} diff --git a/backend/src/test/java/corea/fixture/ParticipationFixture.java b/backend/src/test/java/corea/fixture/ParticipationFixture.java new file mode 100644 index 000000000..460fa65dd --- /dev/null +++ b/backend/src/test/java/corea/fixture/ParticipationFixture.java @@ -0,0 +1,21 @@ +package corea.fixture; + +import corea.matching.domain.Participation; + +import java.util.List; + +public class ParticipationFixture { + + public static List PARTICIPATIONS_EIGHT() { + return List.of( + new Participation(1L, 1L), + new Participation(1L, 2L), + new Participation(1L, 3L), + new Participation(1L, 4L), + new Participation(1L, 5L), + new Participation(1L, 6L), + new Participation(1L, 7L), + new Participation(1L, 8L) + ); + } +} diff --git a/backend/src/test/java/corea/fixture/RoomFixture.java b/backend/src/test/java/corea/fixture/RoomFixture.java new file mode 100644 index 000000000..effc3bcf2 --- /dev/null +++ b/backend/src/test/java/corea/fixture/RoomFixture.java @@ -0,0 +1,31 @@ +package corea.fixture; + +import corea.member.domain.Member; +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public class RoomFixture { + + public static Room ROOM_DOMAIN(Member member) { + return new Room( + "자바 레이싱 카 - MVC", + "MVC 패턴을 아시나요?", + 4, + "https://github.com/example/java-racingcar", + "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + List.of("TDD, 클린코드,자바"), + 17, + 30, + member, + LocalDateTime.now(), + LocalDateTime.now() + .plusDays(14), + Classification.BACKEND, + RoomStatus.OPENED + ); + } +} diff --git a/backend/src/test/java/corea/matching/controller/ParticipateControllerTest.java b/backend/src/test/java/corea/matching/controller/ParticipateControllerTest.java new file mode 100644 index 000000000..5cd658e02 --- /dev/null +++ b/backend/src/test/java/corea/matching/controller/ParticipateControllerTest.java @@ -0,0 +1,37 @@ +package corea.matching.controller; + +import config.ControllerTest; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@ControllerTest +class ParticipateControllerTest { + @Autowired + RoomRepository roomRepository; + @Autowired + MemberRepository memberRepository; + + @Test + @DisplayName("사용자가 방에 참여한다.") + void participate() { + Member manager = memberRepository.save(MemberFixture.MEMBER_JOYSON()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + + Member member = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + //@formatter:off + RestAssured.given().header(new Header("Authorization", member.getEmail())).contentType(ContentType.JSON) + .when().post("/participate/"+room.getId()) + .then().assertThat().statusCode(200); + //@formatter:on + } +} diff --git a/backend/src/test/java/corea/matching/domain/PlainRandomMatchingTest.java b/backend/src/test/java/corea/matching/domain/PlainRandomMatchingTest.java new file mode 100644 index 000000000..14d0672bc --- /dev/null +++ b/backend/src/test/java/corea/matching/domain/PlainRandomMatchingTest.java @@ -0,0 +1,37 @@ +package corea.matching.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlainRandomMatchingTest { + + private final PlainRandomMatching plainRandomMatching = new PlainRandomMatching(); + + @Test + @DisplayName("아이디의 리스트가 들어오면 매칭 사이즈 만큼 매칭된 결과를 반환한다.") + void matchPairs_1() { + List memberIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L); + int matchingSize = 3; + + List result = plainRandomMatching.matchPairs(memberIds, matchingSize); + + assertThat(result).hasSize(matchingSize * memberIds.size()); + } + + @Test + @DisplayName("매칭을 수행할 때에, 본인을 제외한 멤버 중에서 매칭이 된다.") + void matchPairs_2() { + List memberIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L); + int matchingSize = 3; + + List result = plainRandomMatching.matchPairs(memberIds, matchingSize); + + for (Pair pair : result) { + assertThat(pair.getFromMemberId()).isNotEqualTo(pair.getToMemberId()); + } + } +} diff --git a/backend/src/test/java/corea/matching/repository/MatchResultRepositoryTest.java b/backend/src/test/java/corea/matching/repository/MatchResultRepositoryTest.java new file mode 100644 index 000000000..44f434a64 --- /dev/null +++ b/backend/src/test/java/corea/matching/repository/MatchResultRepositoryTest.java @@ -0,0 +1,46 @@ +package corea.matching.repository; + +import corea.fixture.MemberFixture; +import corea.matching.domain.MatchResult; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class MatchResultRepositoryTest { + + @Autowired + private MatchResultRepository matchResultRepository; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("사용자가 입장한 방에서 사용자를 리뷰할 사람들을 조회할 수 있다.") + void findAllByRoomIdAndToMemberId() { + Member member1 = memberRepository.save(MemberFixture.MEMBER_JOYSON()); + Member member2 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Member member3 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + + MatchResult matchResult1 = new MatchResult(1L, member1, member2, null); + MatchResult matchResult2 = new MatchResult(1L, member2, member2, null); + MatchResult matchResult3 = new MatchResult(1L, member3, member2, null); + MatchResult matchResult4 = new MatchResult(1L, member2, member1, null); + + matchResultRepository.save(matchResult1); + matchResultRepository.save(matchResult2); + matchResultRepository.save(matchResult3); + matchResultRepository.save(matchResult4); + + List results = matchResultRepository.findAllByRevieweeIdAndRoomId(2L, 1L); + + assertThat(results).hasSize(3); + } +} diff --git a/backend/src/test/java/corea/matching/service/MatchResultServiceTest.java b/backend/src/test/java/corea/matching/service/MatchResultServiceTest.java new file mode 100644 index 000000000..279efb055 --- /dev/null +++ b/backend/src/test/java/corea/matching/service/MatchResultServiceTest.java @@ -0,0 +1,100 @@ +package corea.matching.service; + +import config.ServiceTest; +import corea.exception.CoreaException; +import corea.matching.domain.Participation; +import corea.matching.dto.MatchResultResponses; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static corea.exception.ExceptionType.MEMBER_NOT_FOUND; +import static corea.exception.ExceptionType.ROOM_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ServiceTest +@ActiveProfiles("test") +@Transactional +class MatchResultServiceTest { + + @Autowired + private MatchingService matchingService; + + @Autowired + private MatchResultService matchResultService; + + @Test + @DisplayName("사용자가 특정 방에서 매칭된 리뷰어 결과를 가져온다.") + void findReviewers() { + long memberId = 1L; + long roomId = 1L; + int matchingSize = 3; + List participations = new ArrayList<>(); + + participations.add(new Participation(roomId, 1L)); + participations.add(new Participation(roomId, 2L)); + participations.add(new Participation(roomId, 3L)); + participations.add(new Participation(roomId, 4L)); + + matchingService.matchMaking(participations, matchingSize); + + MatchResultResponses reviewers = matchResultService.findReviewers(memberId, roomId); + + assertThat(reviewers.matchResultResponses()).hasSize(matchingSize); + } + + @Test + @DisplayName("리뷰어 결과를 가져올 때 존재하지 않는 방이나 사용자의 정보를 요청하는 경우 예외를 발생한다.") + void findReviewersInvalidException() { + long memberId = 1; + long roomId = 0; + + int matchingSize = 3; + List participations = new ArrayList<>(); + + participations.add(new Participation(1L, 1L)); + participations.add(new Participation(1L, 4L)); + participations.add(new Participation(1L, 5L)); + participations.add(new Participation(1L, 6L)); + participations.add(new Participation(1L, 7L)); + + matchingService.matchMaking(participations, matchingSize); + + assertThatThrownBy(() -> matchResultService.findReviewers(memberId, roomId)) + .isInstanceOf(CoreaException.class) + .satisfies(exception -> { + CoreaException coreaException = (CoreaException) exception; + assertThat(coreaException.getExceptionType()).isEqualTo(ROOM_NOT_FOUND); + }); + } + + @Test + @DisplayName("리뷰어 결과를 가져올 때 존재하지 않는 방이나 사용자의 정보를 요청하는 경우 예외를 발생한다.") + void findReviewersInvalidException2() { + long memberId = 8; + long roomId = 1; + int matchingSize = 3; + List participations = new ArrayList<>(); + + participations.add(new Participation(1L, 1L)); + participations.add(new Participation(1L, 4L)); + participations.add(new Participation(1L, 5L)); + participations.add(new Participation(1L, 6L)); + participations.add(new Participation(1L, 7L)); + + matchingService.matchMaking(participations, matchingSize); + + assertThatThrownBy(() -> matchResultService.findReviewers(memberId, roomId)) + .isInstanceOf(CoreaException.class) + .satisfies(exception -> { + CoreaException coreaException = (CoreaException) exception; + assertThat(coreaException.getExceptionType()).isEqualTo(MEMBER_NOT_FOUND); + }); + } +} diff --git a/backend/src/test/java/corea/matching/service/MatchingServiceTest.java b/backend/src/test/java/corea/matching/service/MatchingServiceTest.java new file mode 100644 index 000000000..f70360928 --- /dev/null +++ b/backend/src/test/java/corea/matching/service/MatchingServiceTest.java @@ -0,0 +1,56 @@ +package corea.matching.service; + +import config.ServiceTest; +import corea.exception.CoreaException; +import corea.fixture.MemberFixture; +import corea.fixture.ParticipationFixture; +import corea.matching.domain.Participation; +import corea.member.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.ArrayList; +import java.util.List; + +import static corea.exception.ExceptionType.PARTICIPANT_SIZE_LACK; +import static org.assertj.core.api.Assertions.*; + +@ServiceTest +class MatchingServiceTest { + + @Autowired + private MatchingService matchingService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("인원 수가 매칭 사이즈보다 큰 경우 매칭을 수행한다.") + void matchMaking() { + List participations = new ArrayList<>(); + int matchingSize = 3; + + for (int i = 0; i < 4; i++) { + participations.add(new Participation(1L, memberRepository.save(MemberFixture.MEMBER_YOUNGSU()).getId())); + participations.add(new Participation(1L, memberRepository.save(MemberFixture.MEMBER_JOYSON()).getId())); + } + + assertThatCode(() -> matchingService.matchMaking(participations, matchingSize)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("매칭을 수행할 때에, 인원 수가 매칭 사이즈보다 작거나 같으면 예외를 발생한다.") + void matchMakingLackOfParticipationException() { + List participations = ParticipationFixture.PARTICIPATIONS_EIGHT(); + int matchingSize = 9; + + assertThatThrownBy(() -> matchingService.matchMaking(participations, matchingSize)) + .isInstanceOf(CoreaException.class) + .satisfies(exception -> { + CoreaException coreaException = (CoreaException) exception; + assertThat(coreaException.getExceptionType()).isEqualTo(PARTICIPANT_SIZE_LACK); + }); + } +} diff --git a/backend/src/test/java/corea/matching/service/ParticipationServiceTest.java b/backend/src/test/java/corea/matching/service/ParticipationServiceTest.java new file mode 100644 index 000000000..eba29cfc0 --- /dev/null +++ b/backend/src/test/java/corea/matching/service/ParticipationServiceTest.java @@ -0,0 +1,69 @@ +package corea.matching.service; + +import config.ServiceTest; +import corea.exception.CoreaException; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.matching.dto.ParticipationRequest; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@ServiceTest +class ParticipationServiceTest { + + @Autowired + private ParticipationService sut; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("멤버가 방에 참여한다.") + void participate() { + Member member = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); + + assertThatCode(() -> sut.participate(new ParticipationRequest(room.getId(), member.getId()))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("ID에 해당하는 방이 없으면 예외를 발생한다.") + void participate_throw_exception_when_roomId_not_exist() { + Member member = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + + assertThatCode(() -> sut.participate(new ParticipationRequest(-1, member.getId()))) + .isInstanceOf(CoreaException.class); + } + + @Test + @DisplayName("ID에 해당하는 멤버가 없으면 예외를 발생한다.") + void participate_throw_exception_when_memberId_not_exist() { + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(null)); + + assertThatCode(() -> sut.participate(new ParticipationRequest(room.getId(), -1))) + .isInstanceOf(CoreaException.class); + } + + @Test + @DisplayName("이미 참여중인 방이면 예외를 발생한다.") + void participate_throw_exception_when_already_participate() { + Member member = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); + + sut.participate(new ParticipationRequest(room.getId(), member.getId())); + + assertThatCode(() -> sut.participate(new ParticipationRequest(room.getId(), member.getId()))) + .isInstanceOf(CoreaException.class); + } +} diff --git a/backend/src/test/java/corea/review/service/ReviewServiceTest.java b/backend/src/test/java/corea/review/service/ReviewServiceTest.java new file mode 100644 index 000000000..cf806c12e --- /dev/null +++ b/backend/src/test/java/corea/review/service/ReviewServiceTest.java @@ -0,0 +1,57 @@ +package corea.review.service; + +import config.ServiceTest; +import corea.exception.CoreaException; +import corea.fixture.MatchResultFixture; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.matching.domain.MatchResult; +import corea.matching.domain.ReviewStatus; +import corea.matching.repository.MatchResultRepository; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ServiceTest +class ReviewServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private ReviewService reviewService; + + @Autowired + private MatchResultRepository matchResultRepository; + + @Test + @Transactional + @DisplayName("리뷰를 완료한다.") + void review() { + Member member1 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Member member2 = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(memberRepository.save(MemberFixture.MEMBER_JOYSON()))); + MatchResult matchResult = matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), member1, member2)); + + reviewService.review(room.getId(), member1.getId(), member2.getId()); + assertThat(matchResult.getReviewStatus()).isEqualTo(ReviewStatus.COMPLETE); + } + + @Test + @DisplayName("방과 멤버들에 해당하는 매칭결과가 없으면 예외를 발생한다.") + void review_throw_exception_when_not_exist_room_and_members() { + assertThatThrownBy(() -> reviewService.review(-1, -1, -1)) + .isInstanceOf(CoreaException.class); + } +} diff --git a/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java b/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java new file mode 100644 index 000000000..d9699353f --- /dev/null +++ b/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java @@ -0,0 +1,146 @@ +package corea.room.acceptance; + +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RoomAcceptanceTest { + + @LocalServerPort + int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + @DisplayName("로그인하지 않은 사용자가 방에 대한 정보를 조회할 수 있다.") + void roomWithoutLogin() { + RoomResponse response = RestAssured.given().log().all() + .header("Authorization", "nothing") + .when().get("/rooms/7") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + assertSoftly(softly -> { + softly.assertThat(response.author()).isEqualTo("이상엽"); + softly.assertThat(response.isParticipated()).isEqualTo(false); + }); + } + + @Test + @DisplayName("로그인한 사용자가 방에 대한 정보를 조회할 수 있다.") + void roomWithLogin() { + RoomResponse response = RestAssured.given().log().all() + .header("Authorization", "namejgc@naver.com") + .when().get("/rooms/7") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + assertSoftly(softly -> { + softly.assertThat(response.author()).isEqualTo("이상엽"); + softly.assertThat(response.isParticipated()).isEqualTo(true); + }); + } + + @Test + @DisplayName("로그인하지 않은 멤버가 참여 중인 방을 조회하려고 하면 예외가 발생한다.") + void participatedRoomsWithoutLogin() { + RestAssured.given().log().all() + .header("Authorization", "nothing") + .when().get("/rooms/participated") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 보여준다.") + void participatedRoomsWithLogin() { + RoomResponses response = RestAssured.given().log().all() + .header("Authorization", "namejgc@naver.com") + .when().get("/rooms/participated") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponses.class); + + List rooms = response.rooms(); + + assertSoftly(softly -> { + softly.assertThat(rooms).hasSize(2); + softly.assertThat(rooms.get(0).author()).isEqualTo("강다빈"); + softly.assertThat(rooms.get(1).author()).isEqualTo("이상엽"); + }); + } + + @Test + @DisplayName("로그인하지 않은 사용자가 분야별로 현재 모집 중인 방들을 조회할 수 있다.") + void openedRoomsWithoutLogin() { + RoomResponses response = RestAssured.given().log().all() + .header("Authorization", "nothing") + .when().get("/rooms/opened?classification=be") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponses.class); + + List rooms = response.rooms(); + + assertSoftly(softly -> { + softly.assertThat(rooms).hasSize(2); + softly.assertThat(rooms.get(0).author()).isEqualTo("조경찬"); + softly.assertThat(rooms.get(1).author()).isEqualTo("박민아"); + }); + } + + @Test + @DisplayName("로그인한 사용자가 분야별로 현재 모집 중인 방들을 조회할 수 있다.") + void openedRoomsWithLogin() { + RoomResponses response = RestAssured.given().log().all() + .header("Authorization", "namejgc@naver.com") + .when().get("/rooms/opened?classification=be") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponses.class); + + List rooms = response.rooms(); + + assertSoftly(softly -> { + softly.assertThat(rooms).hasSize(1); + softly.assertThat(rooms.get(0).author()).isEqualTo("박민아"); + }); + } + + @Test + @DisplayName("모집 완료된 방들을 조회할 수 있다.") + void closedRooms() { + RoomResponses response = RestAssured.given().log().all() + .header("Authorization", "namejgc@naver.com") + .when().get("/rooms/closed?classification=all") + .then().log().all() + .statusCode(200) + .extract().as(RoomResponses.class); + + List rooms = response.rooms(); + + assertSoftly(softly -> { + softly.assertThat(rooms).hasSize(3); + softly.assertThat(rooms.get(0).author()).isEqualTo("조경찬"); + softly.assertThat(rooms.get(1).author()).isEqualTo("이영수"); + softly.assertThat(rooms.get(2).author()).isEqualTo("최진실"); + }); + } +} diff --git a/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java b/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java new file mode 100644 index 000000000..78121ae5b --- /dev/null +++ b/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java @@ -0,0 +1,53 @@ +package corea.room.repository; + +import corea.DataInitializer; +import corea.room.domain.Classification; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DataJpaTest +@Import(DataInitializer.class) +@ActiveProfiles("test") +class RoomRepositoryTest { + + @Autowired + private RoomRepository roomRepository; + + @ParameterizedTest + @CsvSource(value = {"ANDROID, 2", "FRONTEND, 1", "BACKEND, 1"}) + @DisplayName("자신이 참여하지 않고, 계속 모집 중인 방들을 조회할 수 있다.") + void findAllByMemberAndClassificationAndStatus(Classification classification, int expectedSize) { + Page rooms = roomRepository.findAllByMemberAndClassificationAndStatus(1, classification, RoomStatus.OPENED, PageRequest.of(0, 8)); + + assertThat(rooms.getContent()).hasSize(expectedSize); + } + + @Test + @DisplayName("분야와 상관 없이 자신이 참여하지 않고, 계속 모집 중인 방들을 조회할 수 있다.") + void findAllByMemberAndStatus() { + Page roomsWithPage = roomRepository.findAllByMemberAndStatus(1, RoomStatus.OPENED, PageRequest.of(0, 8)); + List rooms = roomsWithPage.getContent(); + + assertSoftly(softly -> { + softly.assertThat(rooms.get(0).getId()).isEqualTo(2); + softly.assertThat(rooms.get(1).getId()).isEqualTo(3); + softly.assertThat(rooms.get(2).getId()).isEqualTo(4); + softly.assertThat(rooms.get(3).getId()).isEqualTo(5); + }); + } +} diff --git a/backend/src/test/java/corea/room/service/RoomServiceTest.java b/backend/src/test/java/corea/room/service/RoomServiceTest.java new file mode 100644 index 000000000..900a6b421 --- /dev/null +++ b/backend/src/test/java/corea/room/service/RoomServiceTest.java @@ -0,0 +1,81 @@ +package corea.room.service; + +import corea.auth.domain.AuthInfo; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +class RoomServiceTest { + + @Autowired + private RoomService roomService; + + @ParameterizedTest + @CsvSource({"2, true", "4, false"}) + @DisplayName("해당 방에 자신이 참여 중인지 아닌지를 판단할 수 있다.") + void findOne(long memberId, boolean expected) { + RoomResponse response = roomService.findOne(1, memberId); + + boolean actual = response.isParticipated(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 보여준다.") + void findParticipatedRooms() { + RoomResponses response = roomService.findParticipatedRooms(1); + List rooms = response.rooms(); + + assertSoftly(softly -> { + softly.assertThat(rooms).hasSize(2); + softly.assertThat(rooms.get(0).author()).isEqualTo("강다빈"); + softly.assertThat(rooms.get(1).author()).isEqualTo("이상엽"); + }); + } + + @ParameterizedTest + @CsvSource({"be, 2", "fe, 3", "an, 2", "all, 7"}) + @DisplayName("로그인하지 않은 사용자가 분야별로 현재 모집 중인 방들을 조회할 수 있다.") + void findOpenedRoomsWithoutMember(String expression, int expectedSize) { + AuthInfo anonymous = AuthInfo.getAnonymous(); + + RoomResponses response = roomService.findOpenedRooms(anonymous.getId(), expression, 0); + List rooms = response.rooms(); + + assertThat(rooms).hasSize(expectedSize); + } + + @ParameterizedTest + @CsvSource({"be, 1", "fe, 1", "an, 2", "all, 4"}) + @DisplayName("로그인한 사용자가 자신이 참여하지 않고, 분야별로 현재 모집 중인 방들을 조회할 수 있다.") + void findOpenedRoomsWithMember(String expression, int expectedSize) { + RoomResponses response = roomService.findOpenedRooms(1, expression, 0); + List rooms = response.rooms(); + + assertThat(rooms).hasSize(expectedSize); + } + + @ParameterizedTest + @CsvSource({"be, 1", "fe, 1", "an, 1", "all, 3"}) + @DisplayName("현재 모집 완료된 방들을 조회할 수 있다.") + void findClosedRooms(String expression, int expectedSize) { + RoomResponses response = roomService.findClosedRooms(expression, 0); + List rooms = response.rooms(); + + assertThat(rooms).hasSize(expectedSize); + } +} diff --git a/backend/src/test/java/corea/util/StringToListConverterTest.java b/backend/src/test/java/corea/util/StringToListConverterTest.java new file mode 100644 index 000000000..a6e57b1e6 --- /dev/null +++ b/backend/src/test/java/corea/util/StringToListConverterTest.java @@ -0,0 +1,29 @@ +package corea.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class StringToListConverterTest { + + private StringToListConverter sut = new StringToListConverter(); + + @Test + @DisplayName("배열을 문자열로 변환한다.") + void listToString() { + List list = List.of("TDD", "클린코드"); + String result = sut.convertToDatabaseColumn(list); + assertThat(result).isEqualTo("TDD, 클린코드"); + } + + @Test + @DisplayName("문자열을 배열로 변환한다.") + void StringToList() { + String source = "TDD, 클린코드"; + List list = sut.convertToEntityAttribute(source); + assertThat(list).containsExactly("TDD", "클린코드"); + } +} diff --git a/backend/src/test/resources/clear.sql b/backend/src/test/resources/clear.sql new file mode 100644 index 000000000..02d7f52b6 --- /dev/null +++ b/backend/src/test/resources/clear.sql @@ -0,0 +1,3 @@ +DELETE FROM MATCH_RESULT; +DELETE FROM MEMBER; +DELETE FROM ROOM; diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 6c833b356..816ef6b64 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,7 +1,8 @@ import type { StorybookConfig } from "@storybook/react-webpack5"; +import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin"; const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: [ "@storybook/addon-webpack5-compiler-swc", "@storybook/addon-onboarding", @@ -14,5 +15,18 @@ const config: StorybookConfig = { name: "@storybook/react-webpack5", options: {}, }, + docs: { + autodocs: true, + }, + webpackFinal: async (config) => { + if (!config.resolve) { + config.resolve = {}; + } + if (!config.resolve.plugins) { + config.resolve.plugins = []; + } + config.resolve.plugins.push(new TsconfigPathsPlugin({})); + return config; + }, }; export default config; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts deleted file mode 100644 index 37914b18f..000000000 --- a/frontend/.storybook/preview.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Preview } from "@storybook/react"; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, -}; - -export default preview; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..f2b5de16c --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,26 @@ +import GlobalStyles from "../src/styles/globalStyles"; +import { theme } from "../src/styles/theme"; +import type { Preview } from "@storybook/react"; +import React from "react"; +import { ThemeProvider } from "styled-components"; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export const decorators = [ + (Story) => ( + + + + + ), +]; +export default preview; diff --git a/frontend/jest.config.mjs b/frontend/jest.config.js similarity index 63% rename from frontend/jest.config.mjs rename to frontend/jest.config.js index a9e6ca7f3..b9c6fa215 100644 --- a/frontend/jest.config.mjs +++ b/frontend/jest.config.js @@ -8,4 +8,9 @@ export default { "\\.(css|less|sass|scss)$": "identity-obj-proxy", "^@/(.*)": "/src/$1", }, + testEnvironmentOptions: { + customExportConditions: [""], + }, + setupFiles: ["./jest.polyfills.js"], + setupFilesAfterEnv: ["./jest.setup.js"], }; diff --git a/frontend/jest.polyfills.js b/frontend/jest.polyfills.js new file mode 100644 index 000000000..42eb4810a --- /dev/null +++ b/frontend/jest.polyfills.js @@ -0,0 +1,34 @@ +/** + * @note The block below contains polyfills for Node.js globals + * required for Jest to function when running JSDOM tests. + * These HAVE to be require's and HAVE to be in this exact + * order, since "undici" depends on the "TextEncoder" global API. + * + * Consider migrating to a more modern test runner if + * you don't want to deal with this. + */ + +const { TextDecoder, TextEncoder } = require("node:util"); +const { ReadableStream } = require("node:stream/web"); + +if (globalThis.ReadableStream === undefined) { + globalThis.ReadableStream = ReadableStream; +} + +Object.defineProperties(globalThis, { + TextDecoder: { value: TextDecoder }, + TextEncoder: { value: TextEncoder }, +}); + +const { Blob, File } = require("node:buffer"); +const { fetch, Headers, FormData, Request, Response } = require("undici"); + +Object.defineProperties(globalThis, { + fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + File: { value: File }, + Headers: { value: Headers }, + FormData: { value: FormData }, + Request: { value: Request }, + Response: { value: Response }, +}); diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 000000000..bf40478e9 --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1,10 @@ +const { server } = require("@/mocks/server"); + +// 모든 테스트 전에 MSW 서버를 시작합니다. +beforeAll(() => server.listen()); + +// 각 테스트 후 MSW 핸들러를 리셋합니다. +afterEach(() => server.resetHandlers()); + +// 모든 테스트 후 MSW 서버를 닫습니다. +afterAll(() => server.close()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6dc37f048..a61c06f78 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,10 +11,12 @@ "dependencies": { "@react-icons/all-files": "^4.1.0", "@tanstack/react-query": "^5.51.1", + "axios": "^1.7.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1", - "styled-components": "^6.1.11" + "styled-components": "^6.1.11", + "undici": "^6.19.2" }, "devDependencies": { "@chromatic-com/storybook": "^1.6.1", @@ -36,6 +38,7 @@ "@types/react-icons": "^3.0.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", + "chromatic": "^11.5.5", "clean-webpack-plugin": "^4.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -55,6 +58,7 @@ "stylelint-order": "^6.0.4", "ts-jest": "^29.2.2", "ts-loader": "^9.5.1", + "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.5.3", "webpack": "^5.92.1", "webpack-cli": "^5.1.4", @@ -94,30 +98,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.8.tgz", - "integrity": "sha512-c4IM7OTg6k1Q+AJ153e2mc2QVTezTwnb4VzquwcyiEzGnW0Kedv4do/TrkU98qPeC5LNiMt/QXwIjzYXLBpyZg==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.9.tgz", + "integrity": "sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.8.tgz", - "integrity": "sha512-6AWcmZC/MZCO0yKys4uhg5NlxL0ESF3K6IAaoQ+xSXvPyPyxNWRafP+GDbI88Oh68O7QkJgmEtedWPM9U0pZNg==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", + "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.8", + "@babel/generator": "^7.24.9", "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-module-transforms": "^7.24.8", + "@babel/helper-module-transforms": "^7.24.9", "@babel/helpers": "^7.24.8", "@babel/parser": "^7.24.8", "@babel/template": "^7.24.7", "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.8", + "@babel/types": "^7.24.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -133,12 +137,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.8.tgz", - "integrity": "sha512-47DG+6F5SzOi0uEvK4wMShmn5yY0mVjVJoWTphdY2B4Rx9wHgjK7Yhtr0ru6nE+sn0v38mzrWOlah0p/YlHHOQ==", + "version": "7.24.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.10.tgz", + "integrity": "sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==", "dev": true, "dependencies": { - "@babel/types": "^7.24.8", + "@babel/types": "^7.24.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -308,9 +312,9 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.8.tgz", - "integrity": "sha512-m4vWKVqvkVAWLXfHCCfff2luJj86U+J0/x+0N3ArG/tP0Fq7zky2dYwMbtPmkc/oulkkbjdL3uWzuoBwQ8R00Q==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz", + "integrity": "sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.24.7", @@ -2012,9 +2016,9 @@ } }, "node_modules/@babel/types": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.8.tgz", - "integrity": "sha512-SkSBEHwwJRU52QEVZBmMBnE5Ux2/6WU1grdYyOhpbCNxbmJrDuDCphBzKZSO3taf0zztp+qkWlymE5tVL5l0TA==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz", + "integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.24.8", @@ -2736,28 +2740,28 @@ "dev": true }, "node_modules/@inquirer/confirm": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.14.tgz", - "integrity": "sha512-nbLSX37b2dGPtKWL3rPuR/5hOuD30S+pqJ/MuFiUEgN6GiMs8UMxiurKAMDzKt6C95ltjupa8zH6+3csXNHWpA==", + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.15.tgz", + "integrity": "sha512-CiLGi3JmKGEsia5kYJN62yG/njHydbYIkzSBril7tCaKbsnIqxa2h/QiON9NjfwiKck/2siosz4h7lVhLFocMQ==", "dev": true, "dependencies": { - "@inquirer/core": "^9.0.2", - "@inquirer/type": "^1.4.0" + "@inquirer/core": "^9.0.3", + "@inquirer/type": "^1.5.0" }, "engines": { "node": ">=18" } }, "node_modules/@inquirer/core": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.0.2.tgz", - "integrity": "sha512-nguvH3TZar3ACwbytZrraRTzGqyxJfYJwv+ZwqZNatAosdWQMP1GV8zvmkNlBe2JeZSaw0WYBHZk52pDpWC9qA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.0.3.tgz", + "integrity": "sha512-p2BRZv/vMmpwlU4ZR966vKQzGVCi4VhLjVofwnFLziTQia541T7i1Ar8/LPh+LzjkXzocme+g5Io6MRtzlCcNA==", "dev": true, "dependencies": { - "@inquirer/figures": "^1.0.3", - "@inquirer/type": "^1.4.0", + "@inquirer/figures": "^1.0.4", + "@inquirer/type": "^1.5.0", "@types/mute-stream": "^0.0.4", - "@types/node": "^20.14.9", + "@types/node": "^20.14.11", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.3.2", "cli-spinners": "^2.9.2", @@ -2773,9 +2777,9 @@ } }, "node_modules/@inquirer/core/node_modules/@types/node": { - "version": "20.14.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", - "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2806,18 +2810,18 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.3.tgz", - "integrity": "sha512-ErXXzENMH5pJt5/ssXV0DfWUZqly8nGzf0UcBV9xTnP+KyffE2mqyxIMBrZ8ijQck2nU0TQm40EQB53YreyWHw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.4.tgz", + "integrity": "sha512-R7Gsg6elpuqdn55fBH2y9oYzrU/yKrSmIsDX4ROT51vohrECFzTf2zw9BfUbOW8xjfmM2QbVoVYdTwhrtEKWSQ==", "dev": true, "engines": { "node": ">=18" } }, "node_modules/@inquirer/type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.4.0.tgz", - "integrity": "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.0.tgz", + "integrity": "sha512-L/UdayX9Z1lLN+itoTKqJ/X4DX5DaWu2Sruwt4XgZzMNv32x4qllbzMX4MbJlz0yxAQtU19UvABGOjmdq1u3qA==", "dev": true, "dependencies": { "mute-stream": "^1.0.0" @@ -3885,9 +3889,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.17.1.tgz", - "integrity": "sha512-mCOMec4BKd6BRGBZeSnGiIgwsbLGp3yhVqAD8H+PxiRNEHgDpZb8J1TnrSDlg97t0ySKMQJTHCWBCmBpSmkF6Q==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", + "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", "engines": { "node": ">=14.0.0" } @@ -3929,9 +3933,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.2.2.tgz", - "integrity": "sha512-SN4cSRt3f0qXi5te+yhMseSdQuZntA8lGlASbRmN77YQTpIaGsNiH88xFoky0s9qz531hiRfU1R0ZSMylBwSKw==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.2.4.tgz", + "integrity": "sha512-l1dlzWBBkR/5aullsX8N1ZbYr2bkeHPAaMCRy1jG5BBA8IHbi55JFwmJ8XF2gXkT2GyAZnePzb43RuLXz4KxFQ==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -3945,13 +3949,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.2.2.tgz", - "integrity": "sha512-m/xJe7uKL+kfJx7pQcHwAeIvJ3tdLIpDGrMAVDNDJHcAxfe44cFjIInaV/1HKf3y5Awap+DZFW66ekkxuI9zzA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.2.4.tgz", + "integrity": "sha512-4oU25rFyr4OgMxHe4RpLJ7lxVwUDfdTi1j/YVyHfYv8koTqjagso8bv0uj0ujP5C3dSsVO0sp3/JOfPDkEUtrA==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -3963,13 +3967,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-controls": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.2.2.tgz", - "integrity": "sha512-y241aOANGzT5XBADUIvALwG/xF5eC6UItzmWJaFvOzSBCq74GIA0+Hu9atyFdvFQbXOrdvPWC4jR+9iuBFRxAA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.2.4.tgz", + "integrity": "sha512-e56aUYhxyR8zJJstRAUP3WILhWTcvgRf5bysTtiyjFAL7U47cuCr043+IYEsxLkXhuZTKX2pcYSrjBtT5bYkVA==", "dev": true, "dependencies": { "dequal": "^2.0.2", @@ -3981,21 +3985,21 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-docs": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.2.2.tgz", - "integrity": "sha512-qk/yjAR9RpsSrKLLbeCgb6u58c8TmYqyJSnXgbAozZZNKHBWlIpvZ/hTNYud8qo0coPlxnLdjnZf32TykWGlAg==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.2.4.tgz", + "integrity": "sha512-oyrDw4nGfntu5Hkhr2Qt1wUOyLaVVERQekYyejyir92QhM10UeA7ZarPXNLfCTj7rbTrWmM1Waka9Tsf8TGMrw==", "dev": true, "dependencies": { "@babel/core": "^7.24.4", "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.2.2", - "@storybook/csf-plugin": "8.2.2", + "@storybook/blocks": "8.2.4", + "@storybook/csf-plugin": "8.2.4", "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "8.2.2", + "@storybook/react-dom-shim": "8.2.4", "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "fs-extra": "^11.1.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", @@ -4009,24 +4013,24 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-essentials": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.2.2.tgz", - "integrity": "sha512-yN//BFMbSvNV0+Sll2hcKmgJX06TUKQDm6pZimUjkXczFtOmK7K/UdDmKjWS+qjhfJdWpxdRoEpxoHvvRmNfsA==", - "dev": true, - "dependencies": { - "@storybook/addon-actions": "8.2.2", - "@storybook/addon-backgrounds": "8.2.2", - "@storybook/addon-controls": "8.2.2", - "@storybook/addon-docs": "8.2.2", - "@storybook/addon-highlight": "8.2.2", - "@storybook/addon-measure": "8.2.2", - "@storybook/addon-outline": "8.2.2", - "@storybook/addon-toolbars": "8.2.2", - "@storybook/addon-viewport": "8.2.2", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.2.4.tgz", + "integrity": "sha512-4upNauDJAJxauxnoUpUvzDnLo18C2yTVxgg+Id9wrKpt9C+CYH2oXyXzxoYGucYWZEe7zgCO6rWrGrKEisiLPQ==", + "dev": true, + "dependencies": { + "@storybook/addon-actions": "8.2.4", + "@storybook/addon-backgrounds": "8.2.4", + "@storybook/addon-controls": "8.2.4", + "@storybook/addon-docs": "8.2.4", + "@storybook/addon-highlight": "8.2.4", + "@storybook/addon-measure": "8.2.4", + "@storybook/addon-outline": "8.2.4", + "@storybook/addon-toolbars": "8.2.4", + "@storybook/addon-viewport": "8.2.4", "ts-dedent": "^2.0.0" }, "funding": { @@ -4034,13 +4038,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.2.2.tgz", - "integrity": "sha512-yDTRzzL+IJAymgY32xoZl09BGBVmPOUV2wVNGYcZkkBLvz2GSQMTfUe1/7F4jAx//+rFBu48/MQzsTC7Bk8kPw==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.2.4.tgz", + "integrity": "sha512-Ll/2y0m/q9ko9jFt40qsiee4fds6vpcwwxi3mPAVwRV/J7PpMzPkoLxM54bKpeHiWdTeGCXRguXNvyeQMQf3pg==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -4050,18 +4054,18 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-interactions": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.2.2.tgz", - "integrity": "sha512-zRRuUwm/l41JtTUgjIoQTUgLT99Hsdz9cqKca/8NYo1MGBdEcKE41DH4aBIzKaOKFu7p9q00/o/X1EqYX4LMUA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.2.4.tgz", + "integrity": "sha512-jGGTCKfqZzq3DSZF+cimD8FBcO8X9yu/cNTcxHtx6TN9McV69sTiSzOpGgbWkLjLjP0XU12NQGqFw38tIn7n9Q==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.2.2", - "@storybook/test": "8.2.2", + "@storybook/instrumenter": "8.2.4", + "@storybook/test": "8.2.4", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, @@ -4070,13 +4074,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-links": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.2.2.tgz", - "integrity": "sha512-eGh7O7SgTJMtnuXC0HlRPOegu1njcJS2cnVqjbzjvjxsPSBhbHpdYMi9Q9E7al/FKuqMUOjIR9YLIlmK1AJaqA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.2.4.tgz", + "integrity": "sha512-1FgD6YXdXXSEDrp2aO4LxYt/X7LnBYx7cLlFla+xbn1CZLGqWLLeOT+BFd29wxpzs3u1Tap9r1iz1vRYL5ziyg==", "dev": true, "dependencies": { "@storybook/csf": "0.1.11", @@ -4089,7 +4093,7 @@ }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2" + "storybook": "^8.2.4" }, "peerDependenciesMeta": { "react": { @@ -4098,9 +4102,9 @@ } }, "node_modules/@storybook/addon-measure": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.2.2.tgz", - "integrity": "sha512-3rCo/aMltt5FrBVdr2dYlD8HlE2q9TLKGJZnwh9on4QyL6ArHbdYw0LmyHe/LrFahJ49w1XQZBMSJcAdRkkS7w==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.2.4.tgz", + "integrity": "sha512-bSyE3mGDaaIKoe6Kt/f20YXKsn8WSoJUHrfKA68gbb+H3tegVQaqeS2KY5YzLqvjHe1qSmrO132NJt8RixLOPQ==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -4111,13 +4115,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-onboarding": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-8.2.2.tgz", - "integrity": "sha512-dCdE8Mt/JW6cq6dY7co35Sul/bAkUT3ixaxBrUagFUYUQ/PTYM6p4/B+45RURD5S9z8LVHH1rVgmEeScm3U78w==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-8.2.4.tgz", + "integrity": "sha512-guFRNPoNpLTR6hReGClUZasyMstGR2XmM4fjKg1iVvodw0nI/sZE/8eG2J2pWUGnp5YzFYirLuIZ03QO7edEMg==", "dev": true, "dependencies": { "react-confetti": "^6.1.0" @@ -4127,13 +4131,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-outline": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.2.2.tgz", - "integrity": "sha512-Y+PQtfTNO8GLX5nz+3x5AMfHNvdGvBXazJ29+Rl1ygYN1+Q9ZhRJDE1kAK0wLxb7CG14peAgdYEaQb3Rduv7HQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.2.4.tgz", + "integrity": "sha512-1C6NrvSDREgCZ7o/1n7Ca81uDDzrSrzWiOkh4OeA7PPQ/445cAOX2OMvxzNkKDIT9GLCLNi9M5XIVyGxJVS4dQ==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -4144,26 +4148,26 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.2.2.tgz", - "integrity": "sha512-JGOueOc3EPljlCl9dVSQee0aMYoqGNvN0UH+R6wYJ3bDZ+tUG/iYpsZVPUOvS8vzp3Imk5Is1kzQbQYJtzdGLg==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.2.4.tgz", + "integrity": "sha512-iPnSr+hdz40Uoqg2cimyWf01/Y8GdgdMKB+b47TGIxtn9SEFBXck00ZG8ttwBvEsecu9K9CDt20fIOnr6oK5tQ==", "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.2.2.tgz", - "integrity": "sha512-gkZ8bsjGGP0NuevkT2iKC+szezSy+w4BrBDknf490mRU2K/B2e7TGojf/j/AtxzILMzD4IKzKUXbE/zwcqjZvA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.2.4.tgz", + "integrity": "sha512-58DcoX0xGpWlJfc0iLDjggkVPYzT4JdCZA2ioK9SQXQMsUzGFwR5PAAJv1tivYp7467tNkXvcM3QTb3Q3g8p4g==", "dev": true, "dependencies": { "memoizerific": "^1.11.3" @@ -4173,7 +4177,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/addon-webpack5-compiler-swc": { @@ -4190,9 +4194,9 @@ } }, "node_modules/@storybook/blocks": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.2.2.tgz", - "integrity": "sha512-av0Tryg4toDl2L/d1ABErtsAk9wvM1su6+M4wq5/Go50sk5IjGTldhbZFa9zNOohxLkZwaj0Q5xAgJ1Y+m5KrQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.2.4.tgz", + "integrity": "sha512-Hl2Dpg41YiJLSVXxjEJPjgPShrDJM3RY6HEEOjqTcAADsheX1IHAWXMJSJGMmne3Sew6VdJXPuHBIOFV4suZxg==", "dev": true, "dependencies": { "@storybook/csf": "0.1.11", @@ -4217,7 +4221,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2" + "storybook": "^8.2.4" }, "peerDependenciesMeta": { "react": { @@ -4229,12 +4233,12 @@ } }, "node_modules/@storybook/builder-webpack5": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.2.2.tgz", - "integrity": "sha512-ud6a3pRusbC/TvT1ed15INxSivyL2y2zI61O/MWQZmM8sZOIC6ObdHLtzU4+535IIqiXhPoQ/QiOBbejqjgZvw==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.2.4.tgz", + "integrity": "sha512-O+upj4dsqmQuOJ1MzxypOoVfZ/bN78em/Px6Dks9LUdvLe/bLLXBeB0HsXdmuUE3GZS5LnR8gQQncl5V3pRLkA==", "dev": true, "dependencies": { - "@storybook/core-webpack": "8.2.2", + "@storybook/core-webpack": "8.2.4", "@types/node": "^18.0.0", "@types/semver": "^7.3.4", "browser-assert": "^1.2.1", @@ -4267,7 +4271,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" }, "peerDependenciesMeta": { "typescript": { @@ -4276,9 +4280,9 @@ } }, "node_modules/@storybook/builder-webpack5/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -4288,15 +4292,15 @@ } }, "node_modules/@storybook/codemod": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.2.2.tgz", - "integrity": "sha512-wRUVKLHVUhbLJYKW3QOufUxJGwaUT4jTCD8+HOGpHPdJO3NrwXu186xt4tuPZO2Y/NnacPeCQPsaK5ok4O8o7A==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.2.4.tgz", + "integrity": "sha512-QcZdqjX4NvkVcWR3yI9it3PfqmBOCR+3iY6j4PmG7p5IE0j9kXMKBbeFrBRprSijHKlwcjbc3bRx2SnKF6AFEg==", "dev": true, "dependencies": { "@babel/core": "^7.24.4", "@babel/preset-env": "^7.24.4", "@babel/types": "^7.24.0", - "@storybook/core": "8.2.2", + "@storybook/core": "8.2.4", "@storybook/csf": "0.1.11", "@types/cross-spawn": "^6.0.2", "cross-spawn": "^7.0.3", @@ -4356,10 +4360,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@storybook/components": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.2.4.tgz", + "integrity": "sha512-JLT1RoR/RXX+ZTeFoY85CRHb9Zz3l0PRRUSetEjoIJdnBGeL5C38bs0s9QnYjpCDLUlhdYhTln+GzmbyH8ocpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.4" + } + }, "node_modules/@storybook/core": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.2.2.tgz", - "integrity": "sha512-L4ojYI+Os/i5bCReDIlFgEDQSS94mbJlNU9WRzEGZpqNC5/hbFEC9Tip7P1MiRx9NrewkzU7b+UCP7mi3e4drQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.2.4.tgz", + "integrity": "sha512-jePmsGZT2hhUNQs8ED6+hFVt2m4hrMseO8kkN7Mcsve1MIujzHUS7Gjo4uguBwHJJOtiXB2fw4OSiQCmsXscZA==", "dev": true, "dependencies": { "@storybook/csf": "0.1.11", @@ -4380,9 +4397,9 @@ } }, "node_modules/@storybook/core-webpack": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.2.2.tgz", - "integrity": "sha512-M5wzgNbotVXcfo7WkXIuDxcBl7tTjnQ27lmlSBk+cu63pDvNn4UMDan621FcvxWq2DbjgIj+PASZ4DzM5O+ovA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.2.4.tgz", + "integrity": "sha512-yJeBZ5EIcU1qtZxc4E/0tgNovBiDdMsCOmWTBi724sqlGscvYSGhsI2v9JBvg3fJhnU2whCakeq4IOLOtiMAeQ==", "dev": true, "dependencies": { "@types/node": "^18.0.0", @@ -4393,7 +4410,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/csf": { @@ -4406,9 +4423,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.2.2.tgz", - "integrity": "sha512-3K2RUpDDvq3DT46qAIj2VBC+fzTTebRUcZUsRfS6G1AzaX9p25iClEHiwcJacFkgQKhkci8A/Ly3Z4JJ3b4Pgw==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.2.4.tgz", + "integrity": "sha512-7V2tmeyAwv4/AQiBpB+7fCpphnY1yhcz+Zv9esUOHKqFn5+7u9FKpEXFFcf6fcbqXr2KoNw2F1EnTv3K/SxXrg==", "dev": true, "dependencies": { "unplugin": "^1.3.1" @@ -4418,7 +4435,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/global": { @@ -4441,9 +4458,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.2.2.tgz", - "integrity": "sha512-refwnHqKHhya45MgqakhMG0jKhTiEIAl0aOwAaQy9+zf9ncMIYQAXRQsSZ2Z188lFWE24wbeHKteb62a5ZfWwQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.2.4.tgz", + "integrity": "sha512-szcRjg7XhtobDW4omexWqBRlmRyrKW9p8uF9k6hanJqhHl4iG9D8xbi3SdaRhcn5KN1Wqv6RDAB+kXzHlFfdKA==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -4455,17 +4472,30 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" + } + }, + "node_modules/@storybook/manager-api": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.2.4.tgz", + "integrity": "sha512-ayiOtcGupSeLCi2doEsRpALNPo4MBWYruc+e3jjkeVJQIg9A1ipSogNQh8unuOmq9rezO4/vcNBd6MxLs3xLWg==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.4" } }, "node_modules/@storybook/preset-react-webpack": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.2.2.tgz", - "integrity": "sha512-GJkDtw4Ac8icD66fotGXYE3rmZkIwASpNLOeGzyP4eMMNaf5vlvTDxwkY551cGbnA5P7r4UkGjDiWinB9XE4VQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.2.4.tgz", + "integrity": "sha512-uu77sBOibgPGWhG84eJsQkGv/UwbVnG/gS4CqHvHeuivtZup5vWxwuqh3ifsU7+uX94ZZuFJ5DNuo6194x9CdA==", "dev": true, "dependencies": { - "@storybook/core-webpack": "8.2.2", - "@storybook/react": "8.2.2", + "@storybook/core-webpack": "8.2.4", + "@storybook/react": "8.2.4", "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", "@types/node": "^18.0.0", "@types/semver": "^7.3.4", @@ -4488,7 +4518,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2" + "storybook": "^8.2.4" }, "peerDependenciesMeta": { "typescript": { @@ -4497,9 +4527,9 @@ } }, "node_modules/@storybook/preset-react-webpack/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -4508,14 +4538,31 @@ "node": ">=10" } }, + "node_modules/@storybook/preview-api": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.2.4.tgz", + "integrity": "sha512-IxOiUYYzNnk1OOz3zQBhsa3P1fsgqeMBZcH7TjiQWs9osuWG20oqsFR6+Z3dxoW8IuQHvpnREGKvAbRsDsThcA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.4" + } + }, "node_modules/@storybook/react": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.2.2.tgz", - "integrity": "sha512-U4p/RV78yhjEwEzem8U7wE5/3sSpnqreGsPdAHMCIHd69e9tVeF0rwrTJGp917RClPjBKgEcfelCuvOlby4MrA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.2.4.tgz", + "integrity": "sha512-tRkEeFhwq2GeRsPwFc8dINI5L4mXanXaa7/JreB6ZcUeOZD8d81TWXCH9QyGvxfe0LW+DeNujA91mx5Yja35Zw==", "dev": true, "dependencies": { + "@storybook/components": "^8.2.4", "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "8.2.2", + "@storybook/manager-api": "^8.2.4", + "@storybook/preview-api": "^8.2.4", + "@storybook/react-dom-shim": "8.2.4", + "@storybook/theming": "^8.2.4", "@types/escodegen": "^0.0.6", "@types/estree": "^0.0.51", "@types/node": "^18.0.0", @@ -4542,7 +4589,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2", + "storybook": "^8.2.4", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -4571,9 +4618,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.2.2.tgz", - "integrity": "sha512-4fb1/yT9WXHzHjs0In6orIEZxga5eXd9UaXEFGudBgowCjDUVP9LabDdKTbGusz20lfaAkATsRG/W+EcSLoh8w==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.2.4.tgz", + "integrity": "sha512-p2ypPWuKKFY/ij7yYjvdnrOcfdpxnAJd9D4/2Hm2eVioE4y8HQSND54t9OfkW+498Ez7ph4zW9ez005XqzH/+w==", "dev": true, "funding": { "type": "opencollective", @@ -4582,18 +4629,18 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/react-webpack5": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.2.2.tgz", - "integrity": "sha512-JPR2Lp88KbfRWgnAd4lKFRKuc9Up6YeqbaDb6sptOXXzDM4nOhlRXKqp2tIqyhfiKp3wmu3PksixqD8f8VS9CA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.2.4.tgz", + "integrity": "sha512-ZwJQF8vW6XcdHrmuEX+rNNV9/lmAFs+p/FoDGGhsiUD7fIUX/F9xak0Ug+uhBcCEniY2suXcNHVQIInaH5/B8Q==", "dev": true, "dependencies": { - "@storybook/builder-webpack5": "8.2.2", - "@storybook/preset-react-webpack": "8.2.2", - "@storybook/react": "8.2.2", + "@storybook/builder-webpack5": "8.2.4", + "@storybook/preset-react-webpack": "8.2.4", + "@storybook/react": "8.2.4", "@types/node": "^18.0.0" }, "engines": { @@ -4606,7 +4653,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.2.2", + "storybook": "^8.2.4", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -4616,9 +4663,9 @@ } }, "node_modules/@storybook/react/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -4628,13 +4675,13 @@ } }, "node_modules/@storybook/test": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.2.2.tgz", - "integrity": "sha512-X2qAKErjTh1X7XLAZqCMtU0ZK8JuwdKmgiqU0oXWxIDmCX6/Dm9ZIcdMZHs/S+K/UnIByjNlQpTShLVfRUeN1w==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.2.4.tgz", + "integrity": "sha512-boFjNFja4BNSbQhvmMlTVdQmZh36iM9+8w0sb7IK2e9Xnoi4+utupPNwBLvSsw4bRayK8+mP4Vk46O8h3TaiMw==", "dev": true, "dependencies": { "@storybook/csf": "0.1.11", - "@storybook/instrumenter": "8.2.2", + "@storybook/instrumenter": "8.2.4", "@testing-library/dom": "10.1.0", "@testing-library/jest-dom": "6.4.5", "@testing-library/user-event": "14.5.2", @@ -4647,7 +4694,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.2.2" + "storybook": "^8.2.4" } }, "node_modules/@storybook/test/node_modules/@testing-library/dom": { @@ -4785,6 +4832,19 @@ "node": ">=8" } }, + "node_modules/@storybook/theming": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.2.4.tgz", + "integrity": "sha512-B4HQMzTeg1TgV9uPDIoDkMSnP839Y05I9+Tw60cilAD+jTqrCvMlccHfehsTzJk+gioAflunATcbU05TMZoeIQ==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.4" + } + }, "node_modules/@swc/core": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.5.7.tgz", @@ -4999,20 +5059,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.51.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.1.tgz", - "integrity": "sha512-fJBMQMpo8/KSsWW5ratJR5+IFr7YNJ3K2kfP9l5XObYHsgfVy1w3FJUWU4FT2fj7+JMaEg33zOcNDBo0LMwHnw==", + "version": "5.51.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.3.tgz", + "integrity": "sha512-xgncI1B0OPfSsYcdqKHUxb/OF370GrtK7BxswlllDfyTVw6r3+9VdugJWaVVQT2LiSbkIqEwUteFXR2I0m2iqw==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.51.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.1.tgz", - "integrity": "sha512-s47HKFnQ4HOJAHoIiXcpna/roMMPZJPy6fJ6p4ZNVn8+/onlLBEDd1+xc8OnDuwgvecqkZD7Z2mnSRbcWefrKw==", + "version": "5.51.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.3.tgz", + "integrity": "sha512-eqg1274A/usLluT4aLXypWKeAQ6LepwCB+303Wjw4o1SAgwJaLL7sWQOA/XA2Y/S4BCmTq95jGl5qnT8tmVcoQ==", "dependencies": { - "@tanstack/query-core": "5.51.1" + "@tanstack/query-core": "5.51.3" }, "funding": { "type": "github", @@ -5023,9 +5083,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.3.1.tgz", - "integrity": "sha512-q/WL+vlXMpC0uXDyfsMtc1rmotzLV8Y0gq6q1gfrrDjQeHoeLrqHbxdPvPNAh1i+xuJl7+BezywcXArz7vLqKQ==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.3.2.tgz", + "integrity": "sha512-0bxIdP9mmPiOJ6wHLj8bdJRq+51oddObeCGdEf6PNEhYd93ZYAN+lPRnEOVFtheVwDM7+p+tza3LAQgp0PTudg==", "dev": true, "peer": true, "dependencies": { @@ -5306,12 +5366,12 @@ } }, "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.8.tgz", - "integrity": "sha512-47DG+6F5SzOi0uEvK4wMShmn5yY0mVjVJoWTphdY2B4Rx9wHgjK7Yhtr0ru6nE+sn0v38mzrWOlah0p/YlHHOQ==", + "version": "7.24.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.10.tgz", + "integrity": "sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==", "dev": true, "dependencies": { - "@babel/types": "^7.24.8", + "@babel/types": "^7.24.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -5321,9 +5381,9 @@ } }, "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.8.tgz", - "integrity": "sha512-SkSBEHwwJRU52QEVZBmMBnE5Ux2/6WU1grdYyOhpbCNxbmJrDuDCphBzKZSO3taf0zztp+qkWlymE5tVL5l0TA==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz", + "integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.24.8", @@ -5658,9 +5718,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.6", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", - "integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==", + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", "dev": true }, "node_modules/@types/mdx": { @@ -5691,9 +5751,9 @@ } }, "node_modules/@types/node": { - "version": "18.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", - "integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", + "version": "18.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.40.tgz", + "integrity": "sha512-MIxieZHrm4Ee8XArBIc+Or9HINt2StOmCbgRcXGSJl8q14svRvkZPe7LJq9HKtTI1SK3wU8b91TjntUm7T69Pg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -5884,16 +5944,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.0.tgz", - "integrity": "sha512-py1miT6iQpJcs1BiJjm54AMzeuMPBSPuKPlnT8HlfudbcS5rYeX5jajpLf3mrdRh9dA/Ec2FVUY0ifeVNDIhZw==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz", + "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.16.0", - "@typescript-eslint/type-utils": "7.16.0", - "@typescript-eslint/utils": "7.16.0", - "@typescript-eslint/visitor-keys": "7.16.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/type-utils": "7.16.1", + "@typescript-eslint/utils": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5917,15 +5977,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.0.tgz", - "integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.1.tgz", + "integrity": "sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.16.0", - "@typescript-eslint/types": "7.16.0", - "@typescript-eslint/typescript-estree": "7.16.0", - "@typescript-eslint/visitor-keys": "7.16.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", "debug": "^4.3.4" }, "engines": { @@ -5945,13 +6005,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz", - "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.1.tgz", + "integrity": "sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.16.0", - "@typescript-eslint/visitor-keys": "7.16.0" + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -5962,13 +6022,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz", - "integrity": "sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.1.tgz", + "integrity": "sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.16.0", - "@typescript-eslint/utils": "7.16.0", + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/utils": "7.16.1", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5989,9 +6049,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", - "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.1.tgz", + "integrity": "sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -6002,13 +6062,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", - "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz", + "integrity": "sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.16.0", - "@typescript-eslint/visitor-keys": "7.16.0", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -6030,9 +6090,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -6042,15 +6102,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz", - "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.1.tgz", + "integrity": "sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.16.0", - "@typescript-eslint/types": "7.16.0", - "@typescript-eslint/typescript-estree": "7.16.0" + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -6064,12 +6124,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", - "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz", + "integrity": "sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/types": "7.16.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -6867,8 +6927,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -6885,6 +6944,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -7798,7 +7867,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8166,9 +8234,9 @@ } }, "node_modules/css-loader/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8559,7 +8627,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -8792,9 +8859,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.827", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz", - "integrity": "sha512-VY+J0e4SFcNfQy19MEoMdaIcZLmDCprqvBtkii1WTCTQHpRvf5N8+3kTYCgL/PcntvwQvmMJWTuDPsq+IlhWKQ==", + "version": "1.4.829", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz", + "integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==", "dev": true }, "node_modules/emittery": { @@ -9493,9 +9560,9 @@ } }, "node_modules/eslint-plugin-storybook/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -10177,7 +10244,6 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, "funding": [ { "type": "individual", @@ -10335,9 +10401,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -10362,7 +10428,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -12000,9 +12065,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "peer": true, "bin": { @@ -12054,9 +12119,9 @@ } }, "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "peer": true, "bin": { @@ -13616,9 +13681,9 @@ "peer": true }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "peer": true, "bin": { @@ -14616,7 +14681,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -14625,7 +14689,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -14865,9 +14928,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", - "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.22.0.tgz", + "integrity": "sha512-hxMO1k4ip1uTVGgPbs1hVpYyhz2P91A6tQyH2H9POx3U6T3MdhIcfY8L2hRu/LRmzPFdfduOS0RIDjFlP2urPw==", "dev": true, "engines": { "node": ">=16" @@ -15009,9 +15072,9 @@ "peer": true }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz", + "integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==", "dev": true }, "node_modules/normalize-path": { @@ -16177,6 +16240,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -16398,11 +16466,11 @@ "dev": true }, "node_modules/react-router": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.1.tgz", - "integrity": "sha512-PTXFXGK2pyXpHzVo3rR9H7ip4lSPZZc0bHG5CARmj65fTT6qG7sTngmb6lcYu1gf3y/8KxORoy9yn59pGpCnpg==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.0.tgz", + "integrity": "sha512-bziKjCcDbcxgWS9WlWFcQIVZ2vJHnCP6DGpQDT0l+0PFDasfJKgzf9CM22eTyhFsZkjk8ApCdKjJwKtzqH80jQ==", "dependencies": { - "@remix-run/router": "1.17.1" + "@remix-run/router": "1.18.0" }, "engines": { "node": ">=14.0.0" @@ -16412,12 +16480,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.24.1.tgz", - "integrity": "sha512-U19KtXqooqw967Vw0Qcn5cOvrX5Ejo9ORmOtJMzYWtCT4/WOfFLIZGGsVLxcd9UkBO0mSTZtXqhZBsWlHr7+Sg==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.0.tgz", + "integrity": "sha512-BhcczgDWWgvGZxjDDGuGHrA8HrsSudilqTaRSBYLWDayvo1ClchNIDVt5rldqp6e7Dro5dEFx9Mzc+r292lN0w==", "dependencies": { - "@remix-run/router": "1.17.1", - "react-router": "6.24.1" + "@remix-run/router": "1.18.0", + "react-router": "6.25.0" }, "engines": { "node": ">=14.0.0" @@ -17411,15 +17479,15 @@ } }, "node_modules/storybook": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.2.2.tgz", - "integrity": "sha512-xDT9gyzAEFQNeK7P+Mj/8bNzN+fbm6/4D6ihdSzmczayjydpNjMs74HDHMY6S4Bfu6tRVyEK2ALPGnr6ZVofBA==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.2.4.tgz", + "integrity": "sha512-ASavW8vIHiWpFY+4M6ngeqK5oL4OkxqdpmQYxvRqH0gA1G1hfq/vmDw4YC4GnqKwyWPQh2kaV5JFurKZVaeaDQ==", "dev": true, "dependencies": { "@babel/core": "^7.24.4", "@babel/types": "^7.24.0", - "@storybook/codemod": "8.2.2", - "@storybook/core": "8.2.2", + "@storybook/codemod": "8.2.4", + "@storybook/core": "8.2.4", "@types/semver": "^7.3.4", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", @@ -17537,9 +17605,9 @@ } }, "node_modules/storybook/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -18399,9 +18467,9 @@ } }, "node_modules/terser": { - "version": "5.31.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.2.tgz", - "integrity": "sha512-LGyRZVFm/QElZHy/CPr/O4eNZOZIzsrQ92y4v9UJe/pFJjypje2yI3C2FmPtvUEnhadlSbmG2nXtdcjHOjCfxw==", + "version": "5.31.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.3.tgz", + "integrity": "sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -18753,9 +18821,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -18825,9 +18893,9 @@ } }, "node_modules/ts-loader/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -18871,6 +18939,72 @@ "node": ">=6" } }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz", + "integrity": "sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tsconfig-paths/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -19046,9 +19180,9 @@ } }, "node_modules/ufo": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", - "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "dev": true }, "node_modules/unbox-primitive": { @@ -19066,6 +19200,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.2.tgz", + "integrity": "sha512-JfjKqIauur3Q6biAtHJ564e3bWa8VvT+7cSiOJHFbX4Erv6CLGDpg8z+Fmg/1OI/47RA+GI2QZaF48SSaLvyBA==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index c11451fa4..4256be50b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,8 +2,11 @@ "name": "corea", "version": "1.0.0", "description": "Code Review Area", + "type": "module", "main": "index.js", "scripts": { + "lint": "eslint 'src/**/*.{ts,tsx}'", + "format": "prettier --check .", "dev": "webpack-dev-server --mode=development --open --hot --progress", "build": "webpack --mode=production --progress", "test": "jest", @@ -25,10 +28,12 @@ "dependencies": { "@react-icons/all-files": "^4.1.0", "@tanstack/react-query": "^5.51.1", + "axios": "^1.7.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1", - "styled-components": "^6.1.11" + "styled-components": "^6.1.11", + "undici": "^6.19.2" }, "devDependencies": { "@chromatic-com/storybook": "^1.6.1", @@ -50,6 +55,7 @@ "@types/react-icons": "^3.0.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", + "chromatic": "^11.5.5", "clean-webpack-plugin": "^4.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -69,9 +75,15 @@ "stylelint-order": "^6.0.4", "ts-jest": "^29.2.2", "ts-loader": "^9.5.1", + "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.5.3", "webpack": "^5.92.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/frontend/public/fonts.css b/frontend/public/fonts.css new file mode 100644 index 000000000..65c8ab69c --- /dev/null +++ b/frontend/public/fonts.css @@ -0,0 +1,10 @@ +@font-face { + font-family: "Hanna"; + src: url("/fonts/bmHanna.otf") format("opentype"); + font-weight: normal; + font-style: normal; +} + +body { + font-family: "Hanna", sans-serif; +} diff --git a/frontend/public/fonts/bmHanna.otf b/frontend/public/fonts/bmHanna.otf new file mode 100644 index 000000000..29020e81f Binary files /dev/null and b/frontend/public/fonts/bmHanna.otf differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 790cee17f..285d17bdb 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -5,6 +5,7 @@ Corea +

diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..24fe3a25f --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,284 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.1' +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()) + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/frontend/src/@types/icon.ts b/frontend/src/@types/icon.ts new file mode 100644 index 000000000..7d63006b8 --- /dev/null +++ b/frontend/src/@types/icon.ts @@ -0,0 +1,3 @@ +type IconKind = "person" | "link" | "calendar"; + +export default IconKind; diff --git a/frontend/src/@types/reviewer.ts b/frontend/src/@types/reviewer.ts new file mode 100644 index 000000000..7842c2901 --- /dev/null +++ b/frontend/src/@types/reviewer.ts @@ -0,0 +1,29 @@ +export interface ReviewerInfo { + userId: number; + username: string; + link: string; + isReviewed: boolean; +} + +// { +// "reviewInfo": [ +// { +// "userId" : 1, +// "username" : "youngsu5582", +// "link" : "https://github.com/youngsu5582/java-racing/pull/7", +// "isReviewed" : true +// }, +// { +// "userId" : 2, +// "username" : "youngsu5583", +// "link" : "https://github.com/youngsu5583/java-racing/pull/12", +// "isReviewed" : false +// }, +// { +// "userId" : 3, +// "username" : "youngsu5584", +// "link" : "https://github.com/youngsu5584/java-racing/pull/23", +// "isReviewed" : false +// } +// ] +// } diff --git a/frontend/src/@types/roomInfo.ts b/frontend/src/@types/roomInfo.ts new file mode 100644 index 000000000..d991418be --- /dev/null +++ b/frontend/src/@types/roomInfo.ts @@ -0,0 +1,16 @@ +export interface RoomInfo { + id: number; + title: string; + content: string; + matchingSize: number; + repositoryLink: string; + thumbnailLink: string; + keywords: string[]; + currentParticipantSize: number; + maximumParticipantSize: number; + manager: string; + recruitmentDeadline: string; + reviewDeadline: string; + isParticipated: boolean; + isClosed: boolean; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index abb67dbcc..000000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import styled from "styled-components"; - -const App = () => { - console.log("qwe"); - - return hello world; -}; - -export default App; - -const Container = styled.div` - background: orange; -`; diff --git a/frontend/src/apis/apiClient.ts b/frontend/src/apis/apiClient.ts new file mode 100644 index 000000000..b3502149f --- /dev/null +++ b/frontend/src/apis/apiClient.ts @@ -0,0 +1,15 @@ +import axios from "axios"; +import { serverUrl } from "@/config/serverUrl"; + +const apiClient = axios.create({ + baseURL: serverUrl, +}); + +// api 요청하기 전 수행 +apiClient.interceptors.request.use((config) => { + config.headers["Authorization"] = "choco@gmail.com"; + + return config; +}); + +export default apiClient; diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts new file mode 100644 index 000000000..4534baf9a --- /dev/null +++ b/frontend/src/apis/endpoints.ts @@ -0,0 +1,17 @@ +export const API_ENDPOINTS = { + PARTICIPATED_ROOMS: "/rooms/participated", + OPENED_ROOMS: "/rooms/opened", + CLOSED_ROOMS: "/rooms/closed", + ROOMS: "/rooms", + MY: "/my", + REVIEW_COMPLETE: "/review/complete", + REVIEWERS(roomId: number) { + return `/rooms/${roomId}/reviewers`; + }, + REVIEWEES(roomId: number) { + return `/rooms/${roomId}/reviewees`; + }, + PARTICIPATE_IN(roomId: number) { + return `/participate/${roomId}`; + }, +}; diff --git a/frontend/src/apis/my.api.ts b/frontend/src/apis/my.api.ts new file mode 100644 index 000000000..0ba354bd8 --- /dev/null +++ b/frontend/src/apis/my.api.ts @@ -0,0 +1,21 @@ +import { ReviewerInfo } from "./../@types/reviewer"; +import apiClient from "./apiClient"; +import { API_ENDPOINTS } from "./endpoints"; + +export const getMyReviewers = async (roomId: number): Promise => { + const res = await apiClient<{ reviewInfo: ReviewerInfo[] }>({ + method: "get", + url: API_ENDPOINTS.REVIEWERS(roomId), + }); + + return res.data.reviewInfo; +}; + +export const getMyReviewees = async (roomId: number): Promise => { + const res = await apiClient<{ reviewInfo: ReviewerInfo[] }>({ + method: "get", + url: API_ENDPOINTS.REVIEWEES(roomId), + }); + + return res.data.reviewInfo; +}; diff --git a/frontend/src/apis/queryKeys.ts b/frontend/src/apis/queryKeys.ts new file mode 100644 index 000000000..a51d3e227 --- /dev/null +++ b/frontend/src/apis/queryKeys.ts @@ -0,0 +1,8 @@ +const QUERY_KEYS = { + ROOM_DETAIL_INFO: "roomDetailInfo", + PARTICIPATED_ROOM_LIST: "participatedRoomList", + REVIEWERS: "reviewers", + REVIEWEES: "reviewees", +}; + +export default QUERY_KEYS; diff --git a/frontend/src/apis/rooms.api.ts b/frontend/src/apis/rooms.api.ts new file mode 100644 index 000000000..760f78385 --- /dev/null +++ b/frontend/src/apis/rooms.api.ts @@ -0,0 +1,21 @@ +import apiClient from "./apiClient"; +import { API_ENDPOINTS } from "./endpoints"; +import { RoomInfo } from "@/@types/roomInfo"; + +export const getParticipatedRoomList = async (): Promise<{ roomInfo: RoomInfo[] }> => { + const res = await apiClient<{ roomInfo: RoomInfo[] }>({ + method: "get", + url: API_ENDPOINTS.PARTICIPATED_ROOMS, + }); + + return res.data; +}; + +export const getRoomDetailInfo = async (id: number): Promise => { + const res = await apiClient<{ roomInfo: RoomInfo }>({ + method: "get", + url: `${API_ENDPOINTS.ROOMS}/${id}`, + }); + + return res.data.roomInfo; +}; diff --git a/frontend/src/components/common/button/Button.stories.tsx b/frontend/src/components/common/button/Button.stories.tsx new file mode 100644 index 000000000..a7556a6c7 --- /dev/null +++ b/frontend/src/components/common/button/Button.stories.tsx @@ -0,0 +1,93 @@ +import Button from "./Button"; +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +const meta = { + title: "common/Button", + component: Button, + parameters: { + docs: { + description: { + component: "버튼 컴포넌트", + }, + }, + }, + argTypes: { + variant: { + description: "버튼 종류", + control: { type: "select" }, + options: ["primary", "secondary", "disable"], + }, + size: { + description: "버튼 사이즈", + control: { type: "select" }, + options: ["small", "medium", "large"], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: "primary", + size: "medium", + }, + render: (args) => ( + + ), +}; + +export const 버튼_종류_변경: Story = { + args: { + variant: "primary", + size: "medium", + }, + parameters: { + docs: { + description: { + story: "버튼의 종류를 변경하는 스토리입니다.", + }, + }, + }, + argTypes: { + variant: { + options: ["primary", "secondary", "disable"], + control: { type: "radio" }, + }, + }, + render: (args) => ( + + ), +}; + +export const 버튼_크기_변경: Story = { + args: { + variant: "primary", + size: "large", + }, + parameters: { + docs: { + description: { + story: "버튼의 크기를 변경하는 스토리입니다.", + }, + }, + }, + argTypes: { + size: { + options: ["small", "medium", "large"], + control: { type: "radio" }, + }, + }, + render: (args) => ( + + ), +}; diff --git a/frontend/src/components/common/button/Button.style.ts b/frontend/src/components/common/button/Button.style.ts new file mode 100644 index 000000000..a87016e7b --- /dev/null +++ b/frontend/src/components/common/button/Button.style.ts @@ -0,0 +1,53 @@ +import { css, styled } from "styled-components"; + +export const Button = styled.button<{ + $variant: "primary" | "secondary" | "disable"; + $size: "small" | "medium" | "large"; +}>` + display: flex; + align-items: center; + justify-content: center; + + color: ${({ theme }) => theme.COLOR.white}; + text-align: center; + + &:hover { + opacity: 0.6; + } + + ${(props) => variantStyles[props.$variant]} + ${(props) => sizeStyles[props.$size]} +`; + +const variantStyles = { + primary: css` + background-color: ${({ theme }) => theme.COLOR.primary2}; + `, + secondary: css` + background-color: ${({ theme }) => theme.COLOR.secondary}; + `, + disable: css` + background-color: ${({ theme }) => theme.COLOR.grey1}; + `, +}; + +const sizeStyles = { + small: css` + width: 120px; + padding: 0.1rem 0; + font: ${({ theme }) => theme.TEXT.small}; + border-radius: 5px; + `, + medium: css` + width: 200px; + height: 40px; + font: ${({ theme }) => theme.TEXT.medium}; + border-radius: 5px; + `, + large: css` + width: 100%; + height: 40px; + font: ${({ theme }) => theme.TEXT.medium}; + border-radius: 5px; + `, +}; diff --git a/frontend/src/components/common/button/Button.tsx b/frontend/src/components/common/button/Button.tsx new file mode 100644 index 000000000..677ad58f2 --- /dev/null +++ b/frontend/src/components/common/button/Button.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { ButtonHTMLAttributes } from "react"; +import * as S from "@/components/common/button/Button.style"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "disable"; + size?: "small" | "medium" | "large"; +} + +const Button = ({ children, variant = "primary", size = "medium", ...rest }: ButtonProps) => { + return ( + + {children} + + ); +}; + +export default Button; diff --git a/frontend/src/components/common/contentSection/ContentSection.stories.tsx b/frontend/src/components/common/contentSection/ContentSection.stories.tsx new file mode 100644 index 000000000..0a0b842df --- /dev/null +++ b/frontend/src/components/common/contentSection/ContentSection.stories.tsx @@ -0,0 +1,35 @@ +import ContentSection from "./ContentSection"; +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +const meta = { + title: "common/ContentSection", + component: ContentSection, + parameters: { + docs: { + description: { + component: "콘텐트 섹션 컴포넌트", + }, + }, + }, + argTypes: { + title: { + description: "ContentSection 제목", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "제목", + }, + render: (args) => ( + + ContentSection에는 모든 것이 들어갈 수 있습니다! + + ), +}; diff --git a/frontend/src/components/common/contentSection/ContentSection.style.ts b/frontend/src/components/common/contentSection/ContentSection.style.ts new file mode 100644 index 000000000..e8d63e63d --- /dev/null +++ b/frontend/src/components/common/contentSection/ContentSection.style.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +export const ContentSectionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const ContentSectionTitle = styled.h2` + font: ${({ theme }) => theme.TEXT.large}; +`; diff --git a/frontend/src/components/common/contentSection/ContentSection.tsx b/frontend/src/components/common/contentSection/ContentSection.tsx new file mode 100644 index 000000000..e5a690325 --- /dev/null +++ b/frontend/src/components/common/contentSection/ContentSection.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from "react"; +import * as S from "@/components/common/contentSection/ContentSection.style"; + +interface ContentSectionProps { + title: string; + children?: ReactNode; +} + +const ContentSection = ({ title, children }: ContentSectionProps) => { + return ( + + {title} + {children} + + ); +}; + +export default ContentSection; diff --git a/frontend/src/components/common/header/Header.style.ts b/frontend/src/components/common/header/Header.style.ts new file mode 100644 index 000000000..76666fa14 --- /dev/null +++ b/frontend/src/components/common/header/Header.style.ts @@ -0,0 +1,27 @@ +import styled from "styled-components"; + +export const HeaderContainer = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100vw; + height: 6.5rem; + padding: 0 calc((100vw - 1200px) / 2); + + box-shadow: 0 4px 4px rgb(0 0 0 / 10%); + + div { + font: ${({ theme }) => theme.TEXT.large}; + } + + li { + cursor: pointer; + font: ${({ theme }) => theme.TEXT.semiSmall}; + } + + ul { + display: flex; + gap: 1rem; + } +`; diff --git a/frontend/src/components/common/header/Header.tsx b/frontend/src/components/common/header/Header.tsx new file mode 100644 index 000000000..157276c6b --- /dev/null +++ b/frontend/src/components/common/header/Header.tsx @@ -0,0 +1,16 @@ +import * as S from "@/components/common/header/Header.style"; + +const Header = () => { + return ( + +
CoReA
+
    +
  • 가이드
  • +
  • 랭킹
  • +
  • 마이페이지
  • +
+
+ ); +}; + +export default Header; diff --git a/frontend/src/components/common/icon/Icon.stories.tsx b/frontend/src/components/common/icon/Icon.stories.tsx new file mode 100644 index 000000000..dd871e95a --- /dev/null +++ b/frontend/src/components/common/icon/Icon.stories.tsx @@ -0,0 +1,41 @@ +import Icon from "./Icon"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "common/Icon", + component: Icon, + parameters: { + docs: { + description: { + component: "아이콘 컴포넌트", + }, + }, + }, + argTypes: { + kind: { + description: "아이콘 종류", + }, + onClick: { + description: "아이콘 클릭 이벤트", + }, + color: { + description: "아이콘 색상", + control: { type: "radio" }, + options: ["black", "red", "green"], + }, + size: { + description: "아이콘 크기", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + kind: "person", + color: "black", + }, +}; diff --git a/frontend/src/components/common/icon/Icon.tsx b/frontend/src/components/common/icon/Icon.tsx new file mode 100644 index 000000000..6594abf34 --- /dev/null +++ b/frontend/src/components/common/icon/Icon.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { MouseEventHandler } from "react"; +import { IconType } from "react-icons/lib"; +import { MdCalendarMonth, MdInsertLink, MdPerson } from "react-icons/md"; +import IconKind from "@/@types/icon"; + +const ICON: { [key in IconKind]: IconType } = { + person: MdPerson, + link: MdInsertLink, + calendar: MdCalendarMonth, +}; + +interface IconProps { + kind: IconKind; + onClick?: MouseEventHandler; + color?: string; + size?: string | number; +} + +const Icon = ({ kind, ...props }: IconProps) => { + const TargetIcon = ICON[kind]; + return ; +}; + +export default Icon; diff --git a/frontend/src/components/common/iconButton/IconButton.stories.tsx b/frontend/src/components/common/iconButton/IconButton.stories.tsx new file mode 100644 index 000000000..6daa81a9d --- /dev/null +++ b/frontend/src/components/common/iconButton/IconButton.stories.tsx @@ -0,0 +1,50 @@ +import IconButton from "./IconButton"; +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +const meta = { + title: "common/IconButton", + component: IconButton, + parameters: { + docs: { + description: { + component: "아이콘 버튼 컴포넌트", + }, + }, + }, + argTypes: { + iconKind: { + description: "아이콘 종류", + control: { type: "select" }, + options: ["person", "link", "calendar"], + }, + text: { + description: "아이콘 버튼 텍스트", + control: { type: "text" }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + iconKind: "person", + text: "person", + }, +}; + +export const WithoutText: Story = { + args: { + iconKind: "link", + }, +}; + +export const WithDifferentIcon: Story = { + args: { + iconKind: "calendar", + text: "calendar", + }, +}; diff --git a/frontend/src/components/common/iconButton/IconButton.style.ts b/frontend/src/components/common/iconButton/IconButton.style.ts new file mode 100644 index 000000000..38980fe17 --- /dev/null +++ b/frontend/src/components/common/iconButton/IconButton.style.ts @@ -0,0 +1,38 @@ +import Icon from "../icon/Icon"; +import styled from "styled-components"; + +export const IconButtonContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +export const IconButtonBox = styled.button` + display: flex; + align-items: center; + justify-content: center; + + width: 50px; + height: 50px; + + background-color: transparent; + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 15px; + box-shadow: 0 4px 4px rgb(0 0 0 / 10%); + + &:active { + position: relative; + top: 3px; + box-shadow: 0 1px 1px rgb(0 0 0 / 10%); + } +`; + +export const StyledIcon = styled(Icon)` + width: 30px; + height: 30px; +`; + +export const IconButtonText = styled.p` + font: ${({ theme }) => theme.TEXT.xSmall}; +`; diff --git a/frontend/src/components/common/iconButton/IconButton.tsx b/frontend/src/components/common/iconButton/IconButton.tsx new file mode 100644 index 000000000..a98cfaa84 --- /dev/null +++ b/frontend/src/components/common/iconButton/IconButton.tsx @@ -0,0 +1,21 @@ +import React, { ButtonHTMLAttributes } from "react"; +import * as S from "@/components/common/iconButton/IconButton.style"; +import IconKind from "@/@types/icon"; + +interface IconButtonProps extends ButtonHTMLAttributes { + iconKind: IconKind; + text?: string; +} + +const IconButton = ({ iconKind, text = "", ...rest }: IconButtonProps) => { + return ( + + + + + {text !== "" && {text}} + + ); +}; + +export default IconButton; diff --git a/frontend/src/components/common/label/Label.stories.tsx b/frontend/src/components/common/label/Label.stories.tsx new file mode 100644 index 000000000..c16cd4795 --- /dev/null +++ b/frontend/src/components/common/label/Label.stories.tsx @@ -0,0 +1,48 @@ +import Label from "./Label"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "common/Label", + component: Label, + parameters: { + docs: { + description: { + component: "라벨 컴포넌트", + }, + }, + }, + argTypes: { + type: { + description: "라벨 타입", + control: { type: "select" }, + options: ["keyword", "open", "close"], + }, + text: { + description: "라벨 텍스트 (키워드 타입에만 적용)", + control: { type: "text" }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Keyword: Story = { + args: { + type: "keyword", + text: "FRONTEND", + }, +}; + +export const Open: Story = { + args: { + type: "open", + }, +}; + +export const Close: Story = { + args: { + type: "close", + }, +}; diff --git a/frontend/src/components/common/label/Label.style.ts b/frontend/src/components/common/label/Label.style.ts new file mode 100644 index 000000000..df2658539 --- /dev/null +++ b/frontend/src/components/common/label/Label.style.ts @@ -0,0 +1,42 @@ +import { LabelType } from "./Label"; +import styled, { css } from "styled-components"; + +interface LabelWrapperProps { + type: LabelType; +} + +export const LabelWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + + width: fit-content; + padding: 0 0.4rem; + + font: ${({ theme }) => theme.TEXT.xSmall}; + color: ${({ theme }) => theme.COLOR.black}; + + border-radius: 15px; + + ${({ type, theme }) => { + switch (type) { + case "keyword": + return css` + background-color: ${theme.COLOR.white}; + border: 1px solid ${theme.COLOR.grey1}; + `; + case "open": + return css` + color: ${theme.COLOR.black}; + background-color: ${theme.COLOR.primary1}; + border: 1px solid ${theme.COLOR.primary1}; + `; + case "close": + return css` + color: ${theme.COLOR.white}; + background-color: ${theme.COLOR.primary2}; + border: 1px solid ${theme.COLOR.primary2}; + `; + } + }} +`; diff --git a/frontend/src/components/common/label/Label.tsx b/frontend/src/components/common/label/Label.tsx new file mode 100644 index 000000000..73e658cc5 --- /dev/null +++ b/frontend/src/components/common/label/Label.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import * as S from "@/components/common/label/Label.style"; + +export type LabelType = "keyword" | "open" | "close"; + +interface LabelProps { + text?: string; + type: LabelType; +} + +const Label = ({ text, type }: LabelProps) => { + return ( + + {type === "keyword" && `#${text}`} + {type === "open" && "모집 중"} + {type === "close" && "모집 완료"} + + ); +}; + +export default Label; diff --git a/frontend/src/components/layout/Layout.style.ts b/frontend/src/components/layout/Layout.style.ts new file mode 100644 index 000000000..da01ae315 --- /dev/null +++ b/frontend/src/components/layout/Layout.style.ts @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const ContentContainer = styled.section` + width: 100vw; + min-width: 436px; + max-width: 1200px; + margin: 2rem auto; + padding: 1rem; +`; diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 000000000..b8f479b0d --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; +import Header from "@/components/common/header/Header"; +import * as S from "@/components/layout/Layout.style"; + +const Layout = () => { + return ( + <> +
+ + + + + ); +}; + +export default Layout; diff --git a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts new file mode 100644 index 000000000..85734e3ca --- /dev/null +++ b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts @@ -0,0 +1,41 @@ +import styled from "styled-components"; + +export const MyRevieweeContainer = styled.div` + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 1rem; +`; + +export const MyRevieweeTitle = styled.span` + width: 10rem; + padding: 1rem 2rem; +`; + +export const MyRevieweeContent = styled.span` + display: flex; + flex-direction: column; + gap: 0.2rem; + + width: 10rem; + padding: 1rem 2rem; +`; + +export const MyRevieweeWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.COLOR.grey1}; + } +`; + +export const PRLink = styled.a` + cursor: pointer; + text-decoration: underline !important; + text-underline-offset: 0.3rem; + + &:hover { + color: ${({ theme }) => theme.COLOR.primary2}; + text-decoration: underline !important; + } +`; diff --git a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx new file mode 100644 index 000000000..e189ca856 --- /dev/null +++ b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx @@ -0,0 +1,50 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import Button from "@/components/common/button/Button"; +import Icon from "@/components/common/icon/Icon"; +import * as S from "@/components/roomDetailPage/myReviewee/MyReviewee.style"; +import { getMyReviewees } from "@/apis/my.api"; +import QUERY_KEYS from "@/apis/queryKeys"; + +const MyReviewee = ({ roomId }: { roomId: number }) => { + const { data: revieweeData } = useQuery({ + queryKey: [QUERY_KEYS.REVIEWEES], + queryFn: () => getMyReviewees(roomId), + }); + + if (!revieweeData || revieweeData.length === 0) { + return <>아직 리뷰이가 매칭되지 않았습니다! 조금만 기다려주세요🤗; + } + + return ( + + + 아이디 + PR 링크 + 제출 여부 + + + {revieweeData?.map((reviewee) => ( + + {reviewee.username} + + + + 바로가기 + + + + + + + + ))} + + ); +}; + +export default MyReviewee; diff --git a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts new file mode 100644 index 000000000..df4b7fecb --- /dev/null +++ b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts @@ -0,0 +1,41 @@ +import styled from "styled-components"; + +export const MyReviewerContainer = styled.div` + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 1rem; +`; + +export const MyReviewerTitle = styled.span` + width: 10rem; + padding: 1rem 2rem; +`; + +export const MyReviewerContent = styled.span` + display: flex; + flex-direction: column; + gap: 0.2rem; + + width: 10rem; + padding: 1rem 2rem; +`; + +export const MyReviewerWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.COLOR.grey1}; + } +`; + +export const PRLink = styled.a` + cursor: pointer; + text-decoration: underline !important; + text-underline-offset: 0.3rem; + + &:hover { + color: ${({ theme }) => theme.COLOR.primary2}; + text-decoration: underline !important; + } +`; diff --git a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx new file mode 100644 index 000000000..499402438 --- /dev/null +++ b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import Button from "@/components/common/button/Button"; +import Icon from "@/components/common/icon/Icon"; +import * as S from "@/components/roomDetailPage/myReviewer/MyReviewer.style"; +import { getMyReviewers } from "@/apis/my.api"; +import QUERY_KEYS from "@/apis/queryKeys"; + +const MyReviewer = ({ roomId }: { roomId: number }) => { + const { data: reviewerData } = useQuery({ + queryKey: [QUERY_KEYS.REVIEWEES], + queryFn: () => getMyReviewers(roomId), + }); + + if (!reviewerData || reviewerData.length === 0) { + return <>아직 리뷰어가 매칭되지 않았습니다! 조금만 기다려주세요🤗; + } + + return ( + + + 아이디 + PR 링크 + 제출 여부 + + + {reviewerData.map((reviewer) => ( + + {reviewer.username} + + + + 바로가기 + + + + + + + ))} + + ); +}; + +export default MyReviewer; diff --git a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts new file mode 100644 index 000000000..bd053a61d --- /dev/null +++ b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts @@ -0,0 +1,96 @@ +import styled from "styled-components"; + +export const RoomInfoCardContainer = styled.div` + display: flex; + + width: 100%; + padding-left: 2rem; + + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 1rem; + box-shadow: 0 4px 4px rgb(0 0 0 / 10%); +`; + +export const RoomInfoCardImg = styled.img` + overflow: hidden; + align-self: center; + + width: 15rem; + height: 100%; + + object-fit: scale-down; +`; + +export const RoomInfoCardContent = styled.div` + display: flex; + flex-direction: column; + + width: calc(100% - 15rem); + height: 100%; + padding: 2rem; +`; + +export const RoomHeaderWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + margin-bottom: 1rem; + padding-bottom: 1rem; + + border-bottom: 1px solid ${({ theme }) => theme.COLOR.grey1}; +`; + +export const RoomTitle = styled.span` + font: ${({ theme }) => theme.TEXT.large}; + color: ${({ theme }) => theme.COLOR.black}; +`; + +export const RepositoryLink = styled.a` + cursor: pointer; + + font: ${({ theme }) => theme.TEXT.small}; + color: ${({ theme }) => theme.COLOR.black}; + text-decoration: underline !important; + text-underline-offset: 0.3rem; + + &:hover { + color: ${({ theme }) => theme.COLOR.primary2}; + text-decoration: underline !important; + } +`; + +export const RoomContentBox = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + + &:last-child { + margin-bottom: 0; + } +`; + +export const RoomContentSmall = styled.span` + font: ${({ theme }) => theme.TEXT.small}; + color: ${({ theme }) => theme.COLOR.black}; +`; + +export const RoomTagBox = styled.div` + display: flex; + flex-direction: row; + gap: 1rem; +`; + +export const RoomKeyword = styled.div` + display: flex; + align-items: center; + justify-content: center; + + padding: 0 1rem; + + background-color: ${({ theme }) => theme.COLOR.primary1}; + border: none; + border-radius: 5px; +`; diff --git a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx new file mode 100644 index 000000000..986344c3c --- /dev/null +++ b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx @@ -0,0 +1,57 @@ +import Icon from "@/components/common/icon/Icon"; +import * as S from "@/components/roomDetailPage/roomInfoCard/RoomInfoCard.style"; +import { RoomInfo } from "@/@types/roomInfo"; + +const RoomInfoCard = ({ roomInfo }: { roomInfo: RoomInfo }) => { + if (!roomInfo.keywords) return <>; + + return ( + + + + + {roomInfo.title} + + + 저장소 바로가기 + + + + + + {roomInfo.keywords.map((keyword, index) => ( + + #{keyword} + + ))} + + {roomInfo.content} + + + + + 방 생성자 : {roomInfo.manager} + + + + 현재 참여 인원 : {roomInfo.currentParticipantSize} / {roomInfo.maximumParticipantSize}명 + + + + 상호 리뷰 인원 : {roomInfo.matchingSize}명 + + + + 모집 마감일: {roomInfo.recruitmentDeadline} + + + + 리뷰 마감일: {roomInfo.reviewDeadline} + + + + + ); +}; + +export default RoomInfoCard; diff --git a/frontend/src/components/shared/roomCard/RoomCard.stories.tsx b/frontend/src/components/shared/roomCard/RoomCard.stories.tsx new file mode 100644 index 000000000..022e11562 --- /dev/null +++ b/frontend/src/components/shared/roomCard/RoomCard.stories.tsx @@ -0,0 +1,77 @@ +import RoomCard from "./RoomCard"; +import type { Meta, StoryObj } from "@storybook/react"; +import { RoomInfo } from "@/@types/roomInfo"; + +const meta = { + title: "shared/RoomCard", + component: RoomCard, + parameters: { + docs: { + description: { + component: "방 정보를 표시하는 카드 컴포넌트", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const sampleRoomInfo: RoomInfo = { + id: 1, + title: "자바 레이싱 카 - TDD", + content: "TDD를 배우고 싶은 자 나에게로", + matchingSize: 3, + repositoryLink: "https://github.com/example/java-racingcar", + thumbnailLink: + "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + keywords: ["TDD", "클린코드", "자바"], + currentParticipantSize: 15, + maximumParticipantSize: 20, + manager: "김코딩", + recruitmentDeadline: "2024-07-30T15:00", + reviewDeadline: "2024-08-10T23:59", + isParticipated: true, + isClosed: false, +}; + +export const Default: Story = { + args: { roomInfo: sampleRoomInfo }, +}; + +export const OpenedRoom: Story = { + args: { + roomInfo: { + ...sampleRoomInfo, + isClosed: false, + }, + }, +}; + +export const ClosedRoom: Story = { + args: { + roomInfo: { + ...sampleRoomInfo, + isClosed: true, + }, + }, +}; + +export const LongTitle: Story = { + args: { + roomInfo: { + ...sampleRoomInfo, + title: "이것은 아주 긴 제목입니다. 제목이 길 때 어떻게 보이는지 테스트합니다.", + }, + }, +}; + +export const ManyKeywords: Story = { + args: { + roomInfo: { + ...sampleRoomInfo, + keywords: ["React", "TypeScript", "Storybook", "Jest", "Cypress", "Redux", "GraphQL"], + }, + }, +}; diff --git a/frontend/src/components/shared/roomCard/RoomCard.style.ts b/frontend/src/components/shared/roomCard/RoomCard.style.ts new file mode 100644 index 000000000..ee2e6002d --- /dev/null +++ b/frontend/src/components/shared/roomCard/RoomCard.style.ts @@ -0,0 +1,101 @@ +import styled from "styled-components"; +import media from "@/styles/media"; + +export const RoomCardContainer = styled.div` + overflow: hidden; + display: flex; + flex-direction: column; + + font: ${({ theme }) => theme.TEXT.xSmall}; + + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 15px; + box-shadow: 0 4px 4px rgb(0 0 0 / 10%); + + ${media.small` + width: 140px; + height: 180px; + `} + + ${media.medium` + width: 180px; + height: 220px; + `} + + ${media.large` + width: 200px; + height: 240px; + `} + + &:hover { + ${media.medium` + transform: scale(1.05); + `} + ${media.large` + transform: scale(1.05); + `} + } + + &:active { + position: relative; + top: 3px; + box-shadow: 0 1px 1px rgb(0 0 0 / 10%); + } +`; + +export const RoomInfoThumbnail = styled.img` + width: 100%; + object-fit: scale-down; + + ${media.small` + height: 100px; + `} + + ${media.medium` + height: 120px; + `} + + ${media.large` + height: 130px; + `} +`; + +export const RoomInformation = styled.div` + display: flex; + flex-direction: column; + gap: 0.2rem; + ${media.small` + gap: 0.1rem; + `} + padding: 0.5rem; +`; + +export const RoomTitle = styled.h2` + overflow: hidden; + font: ${({ theme }) => theme.TEXT.small}; + ${media.small` + font: ${({ theme }) => theme.TEXT.semiSmall}; + `} + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const KeywordsContainer = styled.div` + display: flex; + gap: 2px; + margin-bottom: 0; + + ${media.large` + margin-bottom: 0.6rem; + `} +`; + +export const MoreKeywords = styled.span` + font: ${({ theme }) => theme.TEXT.xSmall}; + color: ${({ theme }) => theme.COLOR.grey4}; +`; + +export const EtcContainer = styled.div` + display: flex; + justify-content: space-between; +`; diff --git a/frontend/src/components/shared/roomCard/RoomCard.tsx b/frontend/src/components/shared/roomCard/RoomCard.tsx new file mode 100644 index 000000000..3c2c57089 --- /dev/null +++ b/frontend/src/components/shared/roomCard/RoomCard.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Icon from "@/components/common/icon/Icon"; +import Label from "@/components/common/label/Label"; +import * as S from "@/components/shared/roomCard/RoomCard.style"; +import { RoomInfo } from "@/@types/roomInfo"; +import { MAX_KEYWORDS } from "@/constants/room"; +import { formatDateString } from "@/utils/dateFormatter"; + +const RoomCard = ({ roomInfo }: { roomInfo: RoomInfo }) => { + const displayedKeywords = roomInfo.keywords.slice(0, MAX_KEYWORDS); + const hasMoreKeywords = roomInfo.keywords.length > MAX_KEYWORDS; + + return ( + + + + {roomInfo.title} + + {displayedKeywords.map((keyword) => ( + + + {roomInfo.isClosed ? ( + + {formatDateString(roomInfo.recruitmentDeadline)} + + + ); +}; + +export default RoomCard; diff --git a/frontend/src/components/shared/roomList/RoomList.style.ts b/frontend/src/components/shared/roomList/RoomList.style.ts new file mode 100644 index 000000000..82e53effc --- /dev/null +++ b/frontend/src/components/shared/roomList/RoomList.style.ts @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export const RoomListContainer = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 5rem; + justify-content: space-between; +`; diff --git a/frontend/src/components/shared/roomList/RoomList.tsx b/frontend/src/components/shared/roomList/RoomList.tsx new file mode 100644 index 000000000..d7b49113c --- /dev/null +++ b/frontend/src/components/shared/roomList/RoomList.tsx @@ -0,0 +1,20 @@ +import { Link } from "react-router-dom"; +import RoomCard from "@/components/shared/roomCard/RoomCard"; +import * as S from "@/components/shared/roomList/RoomList.style"; +import { RoomInfo } from "@/@types/roomInfo"; + +const RoomList = ({ roomList }: { roomList: RoomInfo[] }) => { + if (!roomList) return <>; + + return ( + + {roomList.map((roomInfo) => ( + + + + ))} + + ); +}; + +export default RoomList; diff --git a/frontend/src/components/test.style.ts b/frontend/src/components/test.style.ts deleted file mode 100644 index cb4194bcc..000000000 --- a/frontend/src/components/test.style.ts +++ /dev/null @@ -1,12 +0,0 @@ -import styled from "styled-components"; - -const Container = styled.div` - z-index: 99; - - display: none; - - width: 10px; - height: 10px; - margin: 10px; - padding: 10px; -`; diff --git a/frontend/src/components/test.test.ts b/frontend/src/components/test.test.ts deleted file mode 100644 index 9002990b1..000000000 --- a/frontend/src/components/test.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -describe("테스트임", () => { - it("두번째 테스트임", () => { - expect(1 + 1).toBe(2); - }); - - it("ㅌㅌ2", () => { - expect(3 + 3).toBe(5); - }); -}); diff --git a/frontend/src/components/test2.tsx b/frontend/src/components/test2.tsx deleted file mode 100644 index 275069961..000000000 --- a/frontend/src/components/test2.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Test = () => { - return
test
; -}; - -export default Test; diff --git a/frontend/src/config/serverUrl.ts b/frontend/src/config/serverUrl.ts new file mode 100644 index 000000000..990eb233f --- /dev/null +++ b/frontend/src/config/serverUrl.ts @@ -0,0 +1 @@ +export const serverUrl = "http://localhost:8080"; diff --git a/frontend/src/constants/room.ts b/frontend/src/constants/room.ts new file mode 100644 index 000000000..2c7652b31 --- /dev/null +++ b/frontend/src/constants/room.ts @@ -0,0 +1 @@ +export const MAX_KEYWORDS = 3; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index dda6e616a..a452c0e66 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,10 +1,31 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider } from "react-router-dom"; +import { ThemeProvider } from "styled-components"; import router from "@/router"; +import GlobalStyles from "@/styles/globalStyles"; +import { theme } from "@/styles/theme"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +const enableMocking = async () => { + if (process.env.NODE_ENV !== "development") { + return; + } + const { worker } = await import("./mocks/browser"); + return worker.start(); +}; + +const queryClient = new QueryClient(); + +enableMocking().then(() => { + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + + , + ); +}); diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts new file mode 100644 index 000000000..1b7d80fc7 --- /dev/null +++ b/frontend/src/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from "msw/browser"; +import roomHandler from "@/mocks/handler/roomHandler"; + +export const worker = setupWorker(...roomHandler); diff --git a/frontend/src/mocks/handler/roomHandler.ts b/frontend/src/mocks/handler/roomHandler.ts new file mode 100644 index 000000000..3f3e70a5f --- /dev/null +++ b/frontend/src/mocks/handler/roomHandler.ts @@ -0,0 +1,35 @@ +import { HttpResponse, http } from "msw"; +import { API_ENDPOINTS } from "@/apis/endpoints"; +import { serverUrl } from "@/config/serverUrl"; +import reviewInfo from "@/mocks/mockResponse/reviewInfo.json"; +import roomInfo from "@/mocks/mockResponse/roomInfo.json"; +import roomInfos from "@/mocks/mockResponse/roomInfos.json"; + +const roomHandler = [ + http.get(serverUrl + API_ENDPOINTS.PARTICIPATED_ROOMS, () => { + return HttpResponse.json(roomInfos, { status: 200 }); + }), + http.get(serverUrl + API_ENDPOINTS.OPENED_ROOMS, () => { + return HttpResponse.json(roomInfos, { status: 200 }); + }), + http.get(serverUrl + API_ENDPOINTS.CLOSED_ROOMS, () => { + return HttpResponse.json(roomInfos, { status: 200 }); + }), + http.get(serverUrl + API_ENDPOINTS.ROOMS + "/:id", () => { + return HttpResponse.json(roomInfo, { status: 200 }); + }), + http.post(serverUrl + API_ENDPOINTS.PARTICIPATE_IN(1), () => { + return HttpResponse.json(null, { status: 200 }); + }), + http.get(serverUrl + API_ENDPOINTS.REVIEWERS(1), () => { + return HttpResponse.json(reviewInfo, { status: 200 }); + }), + http.get(serverUrl + API_ENDPOINTS.REVIEWEES(1), () => { + return HttpResponse.json(reviewInfo, { status: 200 }); + }), + http.post(serverUrl + API_ENDPOINTS.REVIEW_COMPLETE, () => { + return HttpResponse.json(null, { status: 200 }); + }), +]; + +export default roomHandler; diff --git a/frontend/src/mocks/mockResponse/reviewInfo.json b/frontend/src/mocks/mockResponse/reviewInfo.json new file mode 100644 index 000000000..5a8be8dc1 --- /dev/null +++ b/frontend/src/mocks/mockResponse/reviewInfo.json @@ -0,0 +1,22 @@ +{ + "reviewInfo": [ + { + "userId": 1, + "username": "youngsu5582", + "link": "https://github.com/youngsu5582/java-racing/pull/7", + "isReviewed": true + }, + { + "userId": 2, + "username": "youngsu5583", + "link": "https://github.com/youngsu5583/java-racing/pull/12", + "isReviewed": false + }, + { + "userId": 3, + "username": "youngsu5584", + "link": "https://github.com/youngsu5584/java-racing/pull/23", + "isReviewed": false + } + ] +} diff --git a/frontend/src/mocks/mockResponse/roomInfo.json b/frontend/src/mocks/mockResponse/roomInfo.json new file mode 100644 index 000000000..01802ddaa --- /dev/null +++ b/frontend/src/mocks/mockResponse/roomInfo.json @@ -0,0 +1,18 @@ +{ + "roomInfo": { + "id": 1, + "title": "자바 레이싱 카 - TDD", + "content": "TDD를 배우고 싶은 자 나에게로", + "matchingSize": 3, + "repositoryLink": "https://github.com/example/java-racingcar", + "thumbnailLink": "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + "keywords": ["TDD", "클린코드", "자바"], + "currentParticipantSize": 15, + "maximumParticipantSize": 20, + "manager": "김코딩", + "recruitmentDeadline": "2024-07-30T15:00", + "reviewDeadline": "2024-08-10T23:59", + "isParticipated": true, + "isClosed": false + } +} diff --git a/frontend/src/mocks/mockResponse/roomInfos.json b/frontend/src/mocks/mockResponse/roomInfos.json new file mode 100644 index 000000000..ecea111e3 --- /dev/null +++ b/frontend/src/mocks/mockResponse/roomInfos.json @@ -0,0 +1,36 @@ +{ + "roomInfo": [ + { + "id": 1, + "title": "자바 레이싱 카 - TDD", + "content": "TDD를 배우고 싶은 자 나에게로", + "matchingSize": 3, + "repositoryLink": "https://github.com/example/java-racingcar", + "thumbnailLink": "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + "keywords": ["TDD", "클린코드", "자바"], + "currentParticipantSize": 15, + "maximumParticipantSize": 20, + "manager": "김코딩", + "recruitmentDeadline": "2024-07-30T15:00", + "reviewDeadline": "2024-08-10T23:59", + "isParticipated": true, + "isClosed": false + }, + { + "id": 2, + "title": "자바 레이싱 카 - MVC", + "content": "MVC 패턴을 아시나요?", + "matchingSize": 4, + "repositoryLink": "https://github.com/example/java-racingcar", + "thumbnailLink": "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + "keywords": ["TDD", "클린코드", "자바"], + "currentParticipantSize": 17, + "maximumParticipantSize": 30, + "manager": "최거짓", + "recruitmentDeadline": "2024-07-30T15:00", + "reviewDeadline": "2024-08-10T23:59", + "isParticipated": true, + "isClosed": false + } + ] +} diff --git a/frontend/src/mocks/server.ts b/frontend/src/mocks/server.ts new file mode 100644 index 000000000..86a5deb98 --- /dev/null +++ b/frontend/src/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import roomHandler from "@/mocks/handler/roomHandler"; + +export const server = setupServer(...roomHandler); diff --git a/frontend/src/pages/main/MainPage.style.ts b/frontend/src/pages/main/MainPage.style.ts new file mode 100644 index 000000000..50781335d --- /dev/null +++ b/frontend/src/pages/main/MainPage.style.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + gap: 5rem; +`; diff --git a/frontend/src/pages/main/MainPage.tsx b/frontend/src/pages/main/MainPage.tsx new file mode 100644 index 000000000..3475a043d --- /dev/null +++ b/frontend/src/pages/main/MainPage.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import ContentSection from "@/components/common/contentSection/ContentSection"; +import RoomList from "@/components/shared/roomList/RoomList"; +import * as S from "@/pages/main/MainPage.style"; +import { RoomInfo } from "@/@types/roomInfo"; +import QUERY_KEYS from "@/apis/queryKeys"; +import { getParticipatedRoomList } from "@/apis/rooms.api"; + +const MainPage = () => { + const { data: participatedRoomList } = useQuery({ + queryKey: [QUERY_KEYS.ROOM_DETAIL_INFO], + queryFn: getParticipatedRoomList, + }); + + if (!participatedRoomList) return <>; + + return ( + + + {participatedRoomList && } + + + ); +}; + +export default MainPage; diff --git a/frontend/src/pages/roomDetail/RoomDetailPage.style.ts b/frontend/src/pages/roomDetail/RoomDetailPage.style.ts new file mode 100644 index 000000000..50781335d --- /dev/null +++ b/frontend/src/pages/roomDetail/RoomDetailPage.style.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + gap: 5rem; +`; diff --git a/frontend/src/pages/roomDetail/RoomDetailPage.tsx b/frontend/src/pages/roomDetail/RoomDetailPage.tsx new file mode 100644 index 000000000..23cd1cd20 --- /dev/null +++ b/frontend/src/pages/roomDetail/RoomDetailPage.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import ContentSection from "@/components/common/contentSection/ContentSection"; +import MyReviewee from "@/components/roomDetailPage/myReviewee/MyReviewee"; +import MyReviewer from "@/components/roomDetailPage/myReviewer/MyReviewer"; +import RoomInfoCard from "@/components/roomDetailPage/roomInfoCard/RoomInfoCard"; +import * as S from "@/pages/roomDetail/RoomDetailPage.style"; +import QUERY_KEYS from "@/apis/queryKeys"; +import { getRoomDetailInfo } from "@/apis/rooms.api"; + +const RoomDetailPage = () => { + const params = useParams(); + const missionId = params.id ? Number(params.id) : 0; + + const { data: roomInfo } = useQuery({ + queryKey: [QUERY_KEYS.ROOM_DETAIL_INFO], + queryFn: () => getRoomDetailInfo(missionId), + }); + + if (!roomInfo) return <>; + + return ( + + + + + + + + + + + + + + ); +}; + +export default RoomDetailPage; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 51121ca47..61f23d3c5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,13 +1,20 @@ import { createBrowserRouter } from "react-router-dom"; -import App from "@/App"; +import Layout from "@/components/layout/Layout"; +import MainPage from "@/pages/main/MainPage"; +import RoomDetailPage from "@/pages/roomDetail/RoomDetailPage"; const router = createBrowserRouter([ { path: "/", + element: , children: [ { index: true, - element: , + element: , + }, + { + path: "room/:id", + element: , }, ], }, diff --git a/frontend/src/styles/globalStyles.ts b/frontend/src/styles/globalStyles.ts new file mode 100644 index 000000000..6e8c0fa29 --- /dev/null +++ b/frontend/src/styles/globalStyles.ts @@ -0,0 +1,212 @@ +import { createGlobalStyle } from "styled-components"; + +const globalStyles = createGlobalStyle` + scrollbar-width: none; + + :root { + font-size: 62.5%; + } + + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, + body, + div, + span, + applet, + object, + iframe, + h1, + h2, + h3, + h4, + h5, + h6, + p, + blockquote, + pre, + a, + abbr, + acronym, + address, + big, + cite, + code, + del, + dfn, + em, + img, + ins, + kbd, + q, + s, + samp, + small, + strike, + strong, + sub, + sup, + tt, + var, + b, + u, + i, + center, + dl, + dt, + dd, + ol, + ul, + li, + fieldset, + form, + label, + legend, + table, + caption, + tbody, + tfoot, + thead, + tr, + th, + td, + article, + aside, + canvas, + details, + embed, + figure, + figcaption, + footer, + header, + hgroup, + menu, + nav, + output, + ruby, + section, + summary, + time, + mark, + audio, + video { + margin: 0; + padding: 0; + + font: inherit; + font-size: 100%; + vertical-align: baseline; + + border: 0; + } + + /* HTML5 display-role reset for older browsers */ + article, + aside, + details, + figcaption, + figure, + footer, + header, + hgroup, + menu, + nav, + section { + display: block; + } + + body { + line-height: 1; + } + + ol, + ul { + list-style: none; + } + + blockquote, + q { + quotes: none; + } + + blockquote::before, + blockquote::after, + q::before, + q::after { + content: ''; + content: none; + } + + table { + border-spacing: 0; + border-collapse: collapse; + } + + button { + cursor: pointer; + border: none; + } + + a { + color: inherit; + text-decoration: none !important; + } + + a:hover { + text-decoration: none !important; + } + + a:visited { + color: inherit; + text-decoration: none; + } + + ul, + li { + list-style: none; + } + + /* h1 { + font-size: 2.5em; + } + + h2 { + font-size: 2.2em; + } + + h3 { + font-size: 2em; + } + + h4 { + font-size: 1.6em; + } + + h5 { + font-size: 1.3em; + } */ + + ::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-thumb { + height: 30%; + background: rgb(132 174 225 / 70%); + border-radius: 10px; +} + +::-webkit-scrollbar-track { + background: rgb(132 174 225 / 20%); +} + +`; + +export default globalStyles; diff --git a/frontend/src/styles/media.ts b/frontend/src/styles/media.ts new file mode 100644 index 000000000..65163927e --- /dev/null +++ b/frontend/src/styles/media.ts @@ -0,0 +1,32 @@ +import { CSSObject, Interpolation, css } from "styled-components"; + +export type Breakpoints = "small" | "medium" | "large"; + +export const breakpoints: Record = { + small: "@media (max-width: 639px)", + medium: "@media (max-width: 1047px)", + large: "@media (min-width: 1048px)", +}; + +const media = Object.entries(breakpoints).reduce( + (acc, [key, value]) => { + acc[key as Breakpoints] = ( + first: CSSObject | TemplateStringsArray, + ...interpolations: Interpolation[] + ) => css` + ${value} { + ${css(first, ...interpolations)} + } + `; + return acc; + }, + {} as Record< + Breakpoints, + ( + first: CSSObject | TemplateStringsArray, + ...interpolations: Interpolation[] + ) => ReturnType + >, +); + +export default media; diff --git a/frontend/src/styles/style.d.ts b/frontend/src/styles/style.d.ts new file mode 100644 index 000000000..bad8c37e5 --- /dev/null +++ b/frontend/src/styles/style.d.ts @@ -0,0 +1,6 @@ +import "styled-components"; +import { ThemeType } from "@/styles/theme"; + +declare module "styled-components" { + export interface DefaultTheme extends ThemeType {} +} diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts new file mode 100644 index 000000000..420deaff0 --- /dev/null +++ b/frontend/src/styles/theme.ts @@ -0,0 +1,28 @@ +const COLOR = { + white: "#ffffff", + grey1: "#c6c6c6", + grey2: "#919191", + grey3: "#5e5e5e", + grey4: "#303030", + black: "#000000", + primary1: "#D9F3FF", + primary2: "#84AEE1", + primary3: "#607999", + secondary: "#FF3D45", +}; + +const TEXT = { + xLarge: "800 1.8rem/1.4rem hanna", + large: "700 1.4rem/1.4rem hanna", + medium: "500 1.2rem/1.4rem hanna", + small: "400 1.0rem/1.4rem hanna", + semiSmall: "400 0.8rem/1.4rem hanna", + xSmall: "400 0.6rem/1rem hanna", +}; + +export const theme = { + COLOR, + TEXT, +}; + +export type ThemeType = typeof theme; diff --git a/frontend/src/utils/dateFormatter.ts b/frontend/src/utils/dateFormatter.ts new file mode 100644 index 000000000..6726e2277 --- /dev/null +++ b/frontend/src/utils/dateFormatter.ts @@ -0,0 +1,7 @@ +export const formatDateString = (dateString: string): string => { + const [datePart, timePart] = dateString.split("T"); + const [year, month, day] = datePart.split("-"); + const [hours, minutes] = timePart.split(":"); + + return `~ ${year}년 ${month}월 ${day}일 ${hours}시 ${minutes}분까지`; +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..580cea780 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +interface ImportMetaEnv { + readonly SERVER_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a4319d3c6..c413e4dec 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -114,5 +114,5 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "jest.setup.js"] } diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index dc047569b..48059333a 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -1,9 +1,9 @@ -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); -const path = require("path"); -const webpack = require("webpack"); +import { CleanWebpackPlugin } from "clean-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import path from "path"; +import webpack from "webpack"; -module.exports = (env, argv) => { +export default (env, argv) => { const prod = argv.mode === "production"; return { @@ -11,20 +11,21 @@ module.exports = (env, argv) => { devtool: prod ? "hidden-source-map" : "eval", entry: "./src/index.tsx", output: { - path: path.join(__dirname, "/dist"), + path: path.join(path.resolve(), "/dist"), filename: "index.js", + publicPath: "/", }, devServer: { + historyApiFallback: true, port: 3000, hot: true, }, resolve: { - extensions: [".js", ".jsx", ".ts", ".tsx"], + extensions: [".js", ".jsx", ".ts", ".tsx", ".json", ".mjs"], alias: { - "@": path.resolve(__dirname, "src/"), + "@": path.resolve(path.resolve(), "src/"), }, }, - devtool: prod ? false : "source-map", module: { rules: [ {