diff --git a/backend/.gitattributes b/.gitattributes similarity index 58% rename from backend/.gitattributes rename to .gitattributes index 8af972cd..b403581f 100644 --- a/backend/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /gradlew text eol=lf +*.sh text eol=lf *.bat text eol=crlf -*.jar binary +*.jar binary \ No newline at end of file diff --git a/.github/hooks/pre-commit b/.github/hooks/pre-commit index 4da562c8..bb8075fd 100755 --- a/.github/hooks/pre-commit +++ b/.github/hooks/pre-commit @@ -20,15 +20,17 @@ part=$(echo "$current_branch" | cut -d'/' -f2) #################### 프론트엔드 스크립트 #################### -if [ "$part" = "fe" ]; then +# 3) 대상 파일이 없으면 스킵 +if [ -z "$frontend_files" ]; then + echo "[pre-commit] No frontend files to lint. Skipping." +elif [ "$part" = "fe" ]; then # eslint 실행을 위해 frontend 폴더로 이동 cd frontend echo "[pre-commit] ⏳ Checking lint for staged files..." + # Lint fix 실행 -if ! pnpm exec eslint --fix $frontend_files; then - echo "[pre-commit] ❌ Lint errors remain after fixing. Commit aborted." - exit 1 -fi + printf '%s\0' $frontend_files \ + | xargs -0 -n 80 pnpm exec eslint --fix # Lint로 fix된 파일들을 다시 스테이징 echo "[pre-commit] Staging fixed files..." @@ -38,7 +40,6 @@ fi echo "" fi - #################### 백엔드 스크립트 #################### if [ "$part" = "be" ]; then diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push index 04ade264..24eee039 100755 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash #################### 공통 스크립트 #################### diff --git a/.github/workflows/be-cd.yml b/.github/workflows/be-cd.yml index 719e5e2c..7c1b75ff 100644 --- a/.github/workflows/be-cd.yml +++ b/.github/workflows/be-cd.yml @@ -7,6 +7,8 @@ on: - main paths: - "backend/**" + - "script/deploy/backend_deploy.sh" + - ".github/workflows/be-cd.yml" jobs: deploy: @@ -39,6 +41,16 @@ jobs: run: | ./gradlew clean build -Dspring.profiles.active=prod -x test + - name: Copy deploy_script to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "script/deploy/backend_deploy.sh" + target: "~/app/deploy" + strip_components: 2 + # EC2로 파일 전송 - name: Copy files to EC2 uses: appleboy/scp-action@v0.1.7 @@ -57,5 +69,5 @@ jobs: username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd ~/app - bash deploy_scripts/backend_deploy.sh + cd ~/app/deploy + bash ./backend_deploy.sh diff --git a/.github/workflows/be-ci.yml b/.github/workflows/be-ci.yml index c7b79e74..da1a20c8 100644 --- a/.github/workflows/be-ci.yml +++ b/.github/workflows/be-ci.yml @@ -6,11 +6,12 @@ on: - dev paths: - "backend/**" + - ".github/workflows/be-ci.yml" jobs: backend: runs-on: ubuntu-latest - + defaults: run: working-directory: ./backend diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml new file mode 100644 index 00000000..6b754028 --- /dev/null +++ b/.github/workflows/changesets.yml @@ -0,0 +1,44 @@ +name: Changesets + +on: + push: + branches: + - main + +env: + CI: true + +jobs: + version: + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout code repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set up .npmrc + run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create PR and publish versions + uses: changesets/action@v1 + with: + commit: "[FE-Chore] update versions" + title: "[FE-Chore] update versions" + publish: pnpm changeset publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/fe-cd.yml b/.github/workflows/fe-cd.yml index e730a16f..e0c379b6 100644 --- a/.github/workflows/fe-cd.yml +++ b/.github/workflows/fe-cd.yml @@ -7,6 +7,9 @@ on: - main paths: - "frontend/**" + - "script/deploy/frontend_deploy.sh" + - "script/deploy/nginx/**" + - ".github/workflows/fe-cd.yml" jobs: deploy: @@ -18,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install pnpm uses: pnpm/action-setup@v4 @@ -33,19 +36,65 @@ jobs: - name: Build Frontend working-directory: ./frontend run: | - pnpm install + pnpm install --frozen-lockfile pnpm build + - name: Copy nginx file to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "script/deploy/nginx/*" + target: "~/app/deploy/nginx" + strip_components: 3 + + - name: Copy deploy script to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "script/deploy/frontend_deploy.sh" + target: "~/app/deploy" + strip_components: 2 + + # jq 설치 + - name: Install jq + run: | + sudo apt-get update + sudo apt-get install -y jq + + # 폴더 구조 준비 + - name: Prepare dist folder structure + run: | + mkdir -p frontend/dist/client + mkdir -p frontend/dist/server + cp -r frontend/apps/client/dist/* frontend/dist/client/ + cp -r frontend/apps/server/dist/* frontend/dist/server/ + jq 'del(.devDependencies)' frontend/apps/server/package.json > frontend/dist/server/package.json + # EC2로 파일 전송 - name: Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 + uses: appleboy/scp-action@master with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_SSH_KEY }} - source: "frontend/dist/" + source: "frontend/dist" target: "~/app" + # 서버 pnpm install + - name: Install pnpm on server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/app/frontend/dist/server + pnpm install --prod + # 배포 스크립트 실행 - name: Deploy uses: appleboy/ssh-action@v1.2.0 @@ -54,5 +103,5 @@ jobs: username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd ~/app - bash deploy_scripts/frontend_deploy.sh + cd ~/app/deploy + bash ./frontend_deploy.sh diff --git a/.github/workflows/fe-ci.yml b/.github/workflows/fe-ci.yml new file mode 100644 index 00000000..d9dd454d --- /dev/null +++ b/.github/workflows/fe-ci.yml @@ -0,0 +1,53 @@ +name: fe-ci + +on: + pull_request: + branches: + - dev + paths: + - "frontend/**" + +jobs: + frontend: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./frontend + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set .env file + run: | + echo "${{ secrets.FE_ENV }}" > .env + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Build Frontend + run: pnpm build + + - name: Run Tests + run: pnpm test diff --git a/README.md b/README.md index 171700e9..4736d080 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -# ⏰ 언제 만나? 바쁜 학생들을 위한 가장 완벽한 약속 시간 찾기 -사용자들의 개인 일정을 연동하여 최적의 약속 시간을 추천하는 일정 조율 서비스 +# ⏰ 언제 만나? +### 바쁜 학생들을 위한 가장 완벽한 약속 시간 찾기 +여러 사람의 일정을 한눈에 비교하고, 효율적으로 조율할 수 있어요. 바쁜 일정 속에서도 모임을 쉽게 계획할 수 있도록 돕는 스마트한 일정 조율 플랫폼이에요! 🔗 [배포 링크](https://unjemanna.site/) @@ -8,6 +9,55 @@ --- + + +## ✨ 주요 기능 +### 일정 조율 생성 +#### 📩 초대 링크 공유로 간편한 참여 +일정 조율 논의를 생성한 후, 초대 링크를 공유해 손쉽게 논의에 참여할 수 있어요. + + +| 일정 조율 생성 | 일정 조율 생성 완료 & 초대 링크 복사 | +|-------------------------------------------|-------------------------------------------| +| | | +| 조율할 일정을 생성할 수 있어요. | 일정 조율 링크를 복사하고 공유할 수 있어요. | + +### 일정 조율 초대 참여 및 조회 + +#### 🕒 최적의 후보 일정 추천 +조율할 날짜 범위와 일정이 진행될 시간대를 설정하면, 모든 참여자의 일정을 분석해 가장 적합한 후보 일정을 추천해 줘요. + +| 일정 조율 초대 참여 | 일정 조율 | +|-------------------------------------------|-------------------------------------------| +| | | +| 초대 링크로 일정 조율에 참여할 수 있어요.| 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있어요. | + + + +### 일정 확정 +#### ✅ 확정된 일정 자동 반영 +일정 조율이 완료되면, 확정된 일정이 모든 참여자의 개인 일정에 자동으로 추가돼요. +Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 더욱 편리하게 관리할 수 있어요. + +| 일정 조율 상태 확인 | 일정 확정 | +|-------------------------------------------|-------------------------------------------| +| | | +| 후보 일정에서 조율해야 하는 사람을 알 수 있어요. | 주최자는 후보 일정을 확정해서 일정 조율을 완료할 수 있어요. | + +### 개인 일정 관리 +#### 🔗 Google 캘린더 연동으로 더욱 편리하게 +사용자는 Google 캘린더와 연동하여 자신의 일정을 불러올 수 있어요. +별도의 입력 없이 기존 일정을 기반으로 조율이 가능해요. + +| 내 일정 관리 | 홈 | +|-------------------------------------------|-------------------------------------------| +| | | | +| 내 일정을 생성하고 관리할 수 있어요. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있어요.| + +
+ +--- + ## 🛠️ 기술 스택 | 구분 | 기술 스택 | |---------------|-----------------------------------------------------------------------------------------------------------------------------------| @@ -16,36 +66,21 @@ | **인프라** | ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?logo=amazon-web-services&logoColor=white) ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-%232088FF.svg?style=flat&logo=github-actions&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED.svg?style=flat&logo=docker&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-%23009639.svg?style=flat&logo=nginx&logoColor=white) | | **소통** | ![Swagger](https://img.shields.io/badge/-Swagger-%23Clojure?style=flat&logo=swagger&logoColor=white) ![GitHub Project](https://img.shields.io/badge/GitHub%20Project-121013?logo=github&logoColor=white)| -
--- - ## 🏡 아키텍쳐 - - -
- ---- +### AWS + -## ✨ 메인 기능 -### 공유 일정 - -| 일정 조율 생성 | 일정 조율 생성 완료 | 일정 조율 결과 | -|-------------------------------------------|-------------------------------------------|-------------------------------------------| -| | | | -| 조율할 일정을 생성할 수 있습니다. | 일정 조율 링크를 복사하고 공유할 수 있습니다. | 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있습니다. | - -| 내 일정 관리 | 홈 | -|-------------------------------------------|-------------------------------------------| -| | | | -| 내 일정을 생성하고 관리할 수 있습니다. 구글 캘린더와의 연동을 지원합니다. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있습니다.| +### 시스템 +
--- -## 👥 팀원 구성 +## 👥 팀원 역할 @@ -71,11 +106,22 @@ -
이현영BE BE(팀장)
+ + ✔ 개인/공유 일정 캘린더 구현
✔ 디자인 토큰 & SVG & 이미지 변환 스크립트 작성
✔ 전역 Modal, Notification 관리 & 에러핸들링 + ✔ Single/Range DatePicker 구현
✔ git hook & 슬랙 웹훅 세팅
✔ Fetch 유틸 구현 + ✔ 비트 연산 로직(후보 일정 산출) 구현
✔ 구글 캘린더 API 연동 (+OAuth)
✔ Redis 세팅 (동현)
✔ CI/CD (동권) + + +
--- +## 📋️ ERD + + +--- + ## 🤝 협업 전략 ### 브랜치 구조 @@ -86,4 +132,18 @@ ### 코드리뷰 `Pn룰`을 도입하여 리뷰의 중요도를 리뷰이가 알 수 있도록 합니다. +
+ +--- + +## 📚 팀 문서 +- [🐬 Wiki 메인](https://github.com/softeer5th/Team4-enDolphin/wiki) +- [🤝 팀의 협업 과정이 궁금하다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Together) +- [🔥 백엔드의 비밀이 궁금하다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Backend) +- [💧 프론트엔드의 고민을 알고싶다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Frontend) +- [🍭 회의록](https://bside.notion.site/efba066ebf0d47b98017b5924ce9e30d) + +
+ +--- diff --git a/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java b/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java index d1705067..8ad3c796 100644 --- a/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java +++ b/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java @@ -2,12 +2,18 @@ import endolphin.backend.domain.auth.dto.LoginRequest; import endolphin.backend.domain.auth.dto.OAuthResponse; +import endolphin.backend.domain.auth.dto.AuthTokenResponse; +import endolphin.backend.global.util.CookieUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; + import java.io.IOException; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -17,36 +23,73 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.UriComponentsBuilder; +@Slf4j @Tag(name = "Auth", description = "인증 관리 API") @RestController @RequiredArgsConstructor public class AuthController { private final AuthService authService; + private final CookieUtil cookieUtil; @Value("${app.frontend.url}") private String frontendUrl; - @Value("${app.frontend.callback}") + @Value("${app.frontend.callback.login}") private String frontendCallback; @GetMapping("/api/v1/oauth2/callback") - public void oauthCallback(@RequestParam("code") String code, + public void oauthCallback(@RequestParam(value = "code", required = false) String code, + @RequestParam(value = "error", required = false) String error, HttpServletResponse response) throws IOException { - String googleCode = authService.oauth2Callback(code); - - String redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl) - .path(frontendCallback) - .queryParam("code", googleCode) - .build().toUriString(); + String redirectUrl; + if (error != null || code == null || code.isBlank()) { + log.error("[oauthCallback] error: {}", error); + redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl) + .path(frontendCallback) + .queryParam("error", error) + .build().toUriString(); + } else { + redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl) + .path(frontendCallback) + .queryParam("code", code) + .build().toUriString(); + } response.sendRedirect(redirectUrl); } @Operation(summary = "로그인", description = "로그인하여 JWT 토큰을 발급받습니다.") @PostMapping("/api/v1/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - OAuthResponse response = authService.login(request.code()); - return ResponseEntity.ok(response); + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletResponse response) { + AuthTokenResponse tokens = authService.login(request.code()); + OAuthResponse oAuthResponse = tokens.oAuthResponse(); + + response.addCookie(cookieUtil.createRefreshTokenCookie(tokens.refreshToken())); + + return ResponseEntity.ok(oAuthResponse); + } + + @Operation(summary = "액세스 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") + @PostMapping("/api/v1/refresh-token") + public ResponseEntity refreshToken(HttpServletRequest request, + HttpServletResponse response) { + + AuthTokenResponse newTokens = authService.refresh( + cookieUtil.getRefreshToken(request.getCookies())); + + response.addCookie(cookieUtil.createRefreshTokenCookie(newTokens.refreshToken())); + + return ResponseEntity.ok(newTokens.oAuthResponse()); + } + + @Operation(summary = "로그아웃", description = "로그아웃하여 리프레시 토큰을 무효화합니다.") + @PostMapping("/api/v1/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + authService.logout( + cookieUtil.getRefreshToken(request.getCookies())); + response.addCookie(cookieUtil.deleteRefreshTokenCookie()); + return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java b/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java index 44abd046..069a4c0e 100644 --- a/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java +++ b/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java @@ -1,6 +1,9 @@ package endolphin.backend.domain.auth; +import endolphin.backend.domain.auth.dto.AuthTokenResponse; +import endolphin.backend.domain.auth.dto.TokenDto; import endolphin.backend.domain.user.UserService; +import endolphin.backend.domain.user.dto.LoginUserDto; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.error.exception.OAuthException; import endolphin.backend.global.google.dto.GoogleTokens; @@ -8,12 +11,8 @@ import endolphin.backend.domain.auth.dto.OAuthResponse; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.google.GoogleOAuthService; -import endolphin.backend.global.security.JwtProvider; -import endolphin.backend.global.util.TimeUtil; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; + import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,31 +23,36 @@ public class AuthService { private final GoogleOAuthService googleOAuthService; private final UserService userService; - private final JwtProvider jwtProvider; - - @Value("${jwt.expired}") - private long expired; - - @Transactional(readOnly = true) - public String oauth2Callback(String code) { - if (code == null || code.isBlank()) { - throw new OAuthException(ErrorCode.INVALID_OAUTH_CODE); - } - return code; - } + private final TokenService tokenService; - public OAuthResponse login(String code) { - GoogleTokens tokenResponse = googleOAuthService.getGoogleTokens(code); + public AuthTokenResponse login(String code) { + GoogleTokens tokenResponse = googleOAuthService.getGoogleTokens(code, true); GoogleUserInfo userInfo = googleOAuthService.getUserInfo(tokenResponse.accessToken()); validateUserInfo(userInfo); - User user = userService.upsertUser(userInfo, tokenResponse); + LoginUserDto userDto = userService.upsertUser(userInfo); - String accessToken = jwtProvider.createToken(user.getId(), user.getEmail()); - LocalDateTime expiredAt = TimeUtil.getNow().plus(expired, ChronoUnit.MILLIS); + User user = userDto.user(); + TokenDto accessToken = tokenService.createAccessToken(user); + TokenDto refreshToken = tokenService.createRefreshToken(user); - return new OAuthResponse(accessToken, expiredAt); + return new AuthTokenResponse(OAuthResponse.of(accessToken, userDto), + refreshToken); } + public AuthTokenResponse refresh(String refreshToken) { + Long userId = tokenService.getUserIdFromToken(refreshToken); + User user = userService.getUser(userId); + TokenDto newAccessToken = tokenService.createAccessToken(user); + TokenDto newRefreshToken = tokenService.updateRefreshToken(userId, refreshToken); + + return new AuthTokenResponse( + OAuthResponse.of(newAccessToken), + newRefreshToken); + } + + public void logout(String refreshToken) { + tokenService.deleteRefreshToken(refreshToken); + } private void validateUserInfo(GoogleUserInfo userInfo) { if (userInfo == null) { diff --git a/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java b/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java new file mode 100644 index 00000000..9c244cd5 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package endolphin.backend.domain.auth; + +import endolphin.backend.domain.auth.entity.RefreshToken; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByExpirationBefore(LocalDateTime now); +} diff --git a/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java b/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java new file mode 100644 index 00000000..68845de0 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java @@ -0,0 +1,76 @@ +package endolphin.backend.domain.auth; + +import endolphin.backend.domain.auth.dto.TokenDto; +import endolphin.backend.domain.auth.entity.RefreshToken; +import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.error.exception.RefreshTokenException; +import endolphin.backend.global.security.JwtProvider; +import endolphin.backend.global.util.TimeUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtProvider jwtProvider; + + public Long getUserIdFromToken(String token) { + if (token == null || token.isBlank()) { + throw new RefreshTokenException(ErrorCode.EMPTY_REFRESH_TOKEN); + } + return jwtProvider.getUserId(token); + } + + public TokenDto createAccessToken(User user) { + return jwtProvider.createToken(user.getId(), user.getEmail()); + } + + public TokenDto createRefreshToken(User user) { + TokenDto token = jwtProvider.createToken(user.getId()); + RefreshToken refreshToken = RefreshToken.builder() + .user(user) + .token(token.token()) + .expiration(token.expiredAt()) + .build(); + + refreshTokenRepository.save(refreshToken); + return token; + } + + public TokenDto updateRefreshToken(Long userId, String token) { + RefreshToken refreshToken = getRefreshToken(token); + validateToken(refreshToken); + TokenDto newRefreshToken = jwtProvider.createToken(userId); + refreshToken.updateToken(newRefreshToken); + refreshTokenRepository.save(refreshToken); + return newRefreshToken; + } + + public void deleteRefreshToken(String token) { + refreshTokenRepository.findByToken(token).ifPresent(refreshTokenRepository::delete); + } + + private RefreshToken getRefreshToken(String token) { + return refreshTokenRepository.findByToken(token) + .orElseThrow(() -> new RefreshTokenException(ErrorCode.INVALID_REFRESH_TOKEN)); + } + + private void validateToken(RefreshToken refreshToken) { + if (refreshToken.getExpiration().isBefore(TimeUtil.getNow())) { + deleteToken(refreshToken); + throw new RefreshTokenException(ErrorCode.REFRESH_TOKEN_EXPIRED); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteToken(RefreshToken refreshToken) { + refreshTokenRepository.delete(refreshToken); + } + +} diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java new file mode 100644 index 00000000..7358dfd7 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java @@ -0,0 +1,9 @@ +package endolphin.backend.domain.auth.dto; + + +public record AuthTokenResponse( + OAuthResponse oAuthResponse, + TokenDto refreshToken +) { + +} diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java index eae98666..4c08e0a4 100644 --- a/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java +++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java @@ -1,10 +1,31 @@ package endolphin.backend.domain.auth.dto; +import endolphin.backend.domain.user.dto.LoginUserDto; import java.time.LocalDateTime; public record OAuthResponse( String accessToken, - LocalDateTime expiredAt + LocalDateTime expiredAt, + Boolean isFirstLogin, + Boolean isGoogleCalendarConnected ) { + public static OAuthResponse of(TokenDto accessToken, LoginUserDto loginUserDto) { + return new OAuthResponse( + accessToken.token(), + accessToken.expiredAt(), + loginUserDto.isFirstLogin(), + loginUserDto.isGoogleCalendarConnected() + ); + } + + public static OAuthResponse of(TokenDto accessToken) { + return new OAuthResponse( + accessToken.token(), + accessToken.expiredAt(), + null, + null + ); + } + } diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java new file mode 100644 index 00000000..b9ef03e8 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java @@ -0,0 +1,10 @@ +package endolphin.backend.domain.auth.dto; + +import java.time.LocalDateTime; + +public record TokenDto( + String token, + LocalDateTime expiredAt +) { + +} diff --git a/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java b/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..0f60909c --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java @@ -0,0 +1,51 @@ +package endolphin.backend.domain.auth.entity; + +import endolphin.backend.domain.auth.dto.TokenDto; +import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.base_entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +@Table(name = "refresh_token") +public class RefreshToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private LocalDateTime expiration; + + @Builder + public RefreshToken(User user, String token, LocalDateTime expiration) { + this.user = user; + this.token = token; + this.expiration = expiration; + } + + public void updateToken(TokenDto token) { + this.token = token.token(); + this.expiration = token.expiredAt(); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java new file mode 100644 index 00000000..4c6ad072 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java @@ -0,0 +1,55 @@ +package endolphin.backend.domain.calendar; + +import endolphin.backend.domain.calendar.dto.CalendarResponse; +import endolphin.backend.global.dto.ListResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Tag(name = "Calendar", description = "캘린더 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class CalendarController { + + private final CalendarFacade calendarFacade; + + @Value("${app.frontend.url}") + private String frontendUrl; + + @Value("${app.frontend.callback.google-calendar}") + private String frontendCallback; + + @GetMapping("/calendar/callback/google") + public void subscribeGoogleCalendar(@RequestParam(value = "code", required = false) String code, + @RequestParam(value = "error", required = false) String error, + HttpServletResponse response) throws IOException { + if (error != null) { + log.error("[subscribeGoogleCalendar] error: {}", error); + } + calendarFacade.linkGoogleCalendar(code); + + String redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl) + .path(frontendCallback) + .build().toUriString(); + + response.sendRedirect(redirectUrl); + } + + @GetMapping("/calendar/list") + public ResponseEntity> calendarList() { + ListResponse response = calendarFacade.getCalendarList(); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java new file mode 100644 index 00000000..86570484 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java @@ -0,0 +1,56 @@ +package endolphin.backend.domain.calendar; + +import endolphin.backend.domain.calendar.dto.CalendarResponse; +import endolphin.backend.domain.calendar.entity.Calendar; +import endolphin.backend.domain.calendar.event.GoogleCalendarLinkEvent; +import endolphin.backend.domain.user.UserService; +import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.dto.ListResponse; +import endolphin.backend.global.google.GoogleCalendarService; +import endolphin.backend.global.google.GoogleOAuthService; +import endolphin.backend.global.google.dto.GoogleCalendarDto; +import endolphin.backend.global.google.dto.GoogleTokens; +import endolphin.backend.global.google.dto.GoogleUserInfo; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CalendarFacade { + private final CalendarService calendarService; + private final UserService userService; + + private final GoogleCalendarService googleCalendarService; + private final GoogleOAuthService googleOAuthService; + + private final ApplicationEventPublisher eventPublisher; + + public void linkGoogleCalendar(String code) { + if (code == null || code.isBlank()) { + return; + } + GoogleTokens tokens = googleOAuthService.getGoogleTokens(code, false); + GoogleUserInfo userInfo = googleOAuthService.getUserInfo(tokens.accessToken()); + + + User user = userService.getUserByEmail(userInfo.email()); + user = userService.updateTokens(user, tokens.accessToken(), tokens.refreshToken()); + + GoogleCalendarDto calendarDto = googleCalendarService.getPrimaryCalendar(user); + calendarService.attachGoogleCalendar(calendarDto, user); + + eventPublisher.publishEvent(new GoogleCalendarLinkEvent(user)); + } + + public ListResponse getCalendarList() { + User user = userService.getCurrentUser(); + + List calendars = calendarService.findAllByUserId(user.getId()); + + List response = calendars.stream() + .map(c -> new CalendarResponse(c.getName())).toList(); + return new ListResponse<>(response); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java index aecfa23b..914694f6 100644 --- a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java +++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java @@ -21,4 +21,6 @@ public interface CalendarRepository extends JpaRepository { + "FROM Calendar c " + "WHERE c.user.id IN :userIds") List findCalendarIdsByUserIds(@Param("userIds") List userIds); + + List findAllByUserId(Long userId); } diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarService.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarService.java index 367a3c08..af00ce0e 100644 --- a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarService.java +++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarService.java @@ -12,25 +12,27 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional @RequiredArgsConstructor +@Slf4j public class CalendarService { private final CalendarRepository calendarRepository; - public Calendar createCalendar(GoogleCalendarDto calendar, User user) { - Calendar newCalendar = Calendar.builder() - .calendarId(calendar.id()) + public void attachGoogleCalendar(GoogleCalendarDto googleCalendarDto, User user) { + Calendar calendar = Calendar.builder() .user(user) - .name(calendar.summary()) - .description(calendar.description()) - .build(); - return calendarRepository.save(newCalendar); + .calendarId(googleCalendarDto.id()) + .name(googleCalendarDto.summary()) + .description(googleCalendarDto.description()).build(); + calendarRepository.save(calendar); } @Transactional(readOnly = true) @@ -70,11 +72,6 @@ public void clearSyncToken(String calendarId) { calendarRepository.save(calendar); } - @Transactional(readOnly = true) - public boolean isExistingCalendar(Long userId) { - return calendarRepository.existsByUserId(userId); - } - @Transactional(readOnly = true) public Calendar getCalendarByUserId(Long userId) { return calendarRepository.findByUserId(userId).orElseThrow( @@ -96,4 +93,9 @@ public Map getCalendarIdByUsers(List userIds) { o -> (String) o[1] )); } + + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + return calendarRepository.findAllByUserId(userId); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/dto/CalendarResponse.java b/backend/src/main/java/endolphin/backend/domain/calendar/dto/CalendarResponse.java new file mode 100644 index 00000000..94fce799 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/calendar/dto/CalendarResponse.java @@ -0,0 +1,7 @@ +package endolphin.backend.domain.calendar.dto; + +public record CalendarResponse( + String name +) { + +} diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/event/GoogleCalendarLinkEvent.java b/backend/src/main/java/endolphin/backend/domain/calendar/event/GoogleCalendarLinkEvent.java new file mode 100644 index 00000000..786f9fe4 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/calendar/event/GoogleCalendarLinkEvent.java @@ -0,0 +1,8 @@ +package endolphin.backend.domain.calendar.event; + +import endolphin.backend.domain.user.entity.User; + +public record GoogleCalendarLinkEvent( + User user +) { +} diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/event/handler/CalendarEventHandler.java b/backend/src/main/java/endolphin/backend/domain/calendar/event/handler/CalendarEventHandler.java new file mode 100644 index 00000000..55669640 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/calendar/event/handler/CalendarEventHandler.java @@ -0,0 +1,25 @@ +package endolphin.backend.domain.calendar.event.handler; + +import endolphin.backend.domain.calendar.CalendarService; +import endolphin.backend.global.google.event.SyncCalendarNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CalendarEventHandler { + + private final CalendarService calendarService; + + @Async + @TransactionalEventListener(classes = {SyncCalendarNotificationEvent.class}) + public void setCalendarProperties(SyncCalendarNotificationEvent event) { + String calendarId = event.calendarId(); + String channelId = event.channelId(); + String resourceId = event.resourceId(); + String channelExpiration = event.channelExpiration(); + calendarService.setWebhookProperties(calendarId, resourceId, channelId, channelExpiration); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/candidate_event/CandidateEventService.java b/backend/src/main/java/endolphin/backend/domain/candidate_event/CandidateEventService.java index 9138bfc5..cec0a809 100644 --- a/backend/src/main/java/endolphin/backend/domain/candidate_event/CandidateEventService.java +++ b/backend/src/main/java/endolphin/backend/domain/candidate_event/CandidateEventService.java @@ -21,7 +21,9 @@ import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.redis.DiscussionBitmapService; +import java.time.Duration; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; @@ -30,10 +32,12 @@ import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@Slf4j(topic = "searchingSpeed") public class CandidateEventService { private final DiscussionBitmapService discussionBitmapService; @@ -111,8 +115,9 @@ public RankViewResponse getEventsOnRankView(Long discussionId, RankViewRequest r } public List searchCandidateEvents(Discussion discussion, int filter) { + LocalDateTime now = getNow(); long searchingNow = getSearchingStartTime( - roundUpToNearestHalfHour(getNow()), + roundUpToNearestHalfHour(now), discussion.getDateRangeStart(), discussion.getTimeRangeStart()); long endDateTime = convertToMinute(discussion.getDateRangeEnd() @@ -157,6 +162,10 @@ public List searchCandidateEvents(Discussion discussion, int fil searchingNow += 30; } + Duration d = Duration.between(now, getNow()); + + log.info("searching speed: {} ms", d.toMillis()); + return events; } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionController.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionController.java index b8af4e56..a1cb99bc 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionController.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionController.java @@ -14,6 +14,7 @@ import endolphin.backend.domain.discussion.dto.InvitationInfo; import endolphin.backend.domain.discussion.dto.JoinDiscussionRequest; import endolphin.backend.domain.discussion.dto.JoinDiscussionResponse; +import endolphin.backend.domain.discussion.dto.UpdateDiscussionRequest; import endolphin.backend.domain.shared_event.dto.SharedEventRequest; import endolphin.backend.domain.shared_event.dto.SharedEventWithDiscussionInfoResponse; import endolphin.backend.global.error.ErrorResponse; @@ -269,4 +270,64 @@ public ResponseEntity getSharedEvent( @PathVariable("discussionId") @Min(1) Long discussionId) { return ResponseEntity.ok(discussionService.getSharedEventInfo(discussionId)); } + + @Operation(summary = "논의 재시작", description = "종료된 논의를 다시 시작합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "논의 재시작 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "허락되지 않은 유저"), + @ApiResponse(responseCode = "404", description = "해당 논의 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping("/{discussionId}/restart") + public ResponseEntity restartDiscussion( + @PathVariable("discussionId") @Min(1) Long discussionId) { + discussionService.restartDiscussion(discussionId); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "논의 수정", description = "논의 기본 정보를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "논의 수정 성공", + content = @Content(schema = @Schema(implementation = DiscussionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "허락되지 않은 유저", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 논의 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PutMapping("/{discussionId}") + public ResponseEntity updateDiscussion( + @PathVariable("discussionId") @Min(1) Long discussionId, + @RequestBody @Valid UpdateDiscussionRequest request) { + DiscussionResponse response = discussionService.updateDiscussion(discussionId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "논의 삭제", description = "논의를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "논의 삭제 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "허락되지 않은 유저", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 논의 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/{discussionId}") + public ResponseEntity deleteDiscussion( + @PathVariable("discussionId") @Min(1) Long discussionId) { + discussionService.deleteDiscussion(discussionId); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantRepository.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantRepository.java index c6eef122..2ec5909b 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantRepository.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantRepository.java @@ -4,11 +4,15 @@ import endolphin.backend.domain.discussion.entity.DiscussionParticipant; import endolphin.backend.domain.user.dto.UserIdNameDto; import endolphin.backend.domain.user.entity.User; +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -29,6 +33,11 @@ public interface DiscussionParticipantRepository extends Optional findOffsetByDiscussionIdAndUserId(@Param("discussionId") Long discussionId, @Param("userId") Long userId); + @Query("SELECT dp FROM DiscussionParticipant dp JOIN FETCH dp.user " + + "WHERE dp.discussion.id = :discussionId") + List findByDiscussionIdWithUser( + @Param("discussionId") Long discussionId); + @Query("SELECT COALESCE(MAX(dp.userOffset), -1) " + "FROM DiscussionParticipant dp " + "WHERE dp.discussion.id = :discussionId") @@ -46,11 +55,12 @@ Optional findOffsetByDiscussionIdAndUserId(@Param("discussionId") Long dis "ORDER BY dp.userOffset ASC") List findUserIdNameDtosByDiscussionId(@Param("discussionId") Long discussionId); - @Query("SELECT dp.userOffset, new endolphin.backend.domain.user.dto.UserIdNameDto(u.id, u.name) " + - "FROM DiscussionParticipant dp " + - "JOIN dp.user u " + - "WHERE dp.discussion.id = :discussionId " + - "ORDER BY dp.userOffset ASC") + @Query( + "SELECT dp.userOffset, new endolphin.backend.domain.user.dto.UserIdNameDto(u.id, u.name) " + + "FROM DiscussionParticipant dp " + + "JOIN dp.user u " + + "WHERE dp.discussion.id = :discussionId " + + "ORDER BY dp.userOffset ASC") List findUserIdNameDtosWithOffset(@Param("discussionId") Long discussionId); @Query("SELECT dp.isHost " + @@ -110,4 +120,27 @@ Page findFinishedDiscussions(@Param("userId") Long userId, "WHERE d.discussionStatus = 'ONGOING' " + "AND dp.user.id IN :userIds") List findOffsetsByUserIds(@Param("userIds") List userIds); + + List findByDiscussionId(Long discussionId); + + @Modifying + @Query("DELETE " + + "FROM DiscussionParticipant dp " + + "WHERE dp.discussion.id = :discussionId " + + "AND dp.user.id = :userId") + void deleteByDiscussionIdAndUserId( + @Param("discussionId") Long discussionId, + @Param("userId") Long userId); + + @Query("SELECT dp.userOffset " + + "FROM DiscussionParticipant dp " + + "WHERE dp.discussion.id = :discussionId " + + "ORDER BY dp.userOffset ASC") + List findOffsetsByDiscussionId( + @Param("discussionId") Long discussionId + ); + @Modifying(clearAutomatically = true) + @Query("DELETE FROM DiscussionParticipant dp WHERE dp.discussion.id = :discussionId") + void deleteAllByDiscussionId(@Param("discussionId") Long discussionId); + } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantService.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantService.java index 78a9a1e3..d017658b 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantService.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionParticipantService.java @@ -15,6 +15,7 @@ import endolphin.backend.global.dto.ListResponse; import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.redis.DiscussionBitmapService; import endolphin.backend.global.util.TimeUtil; import java.util.ArrayList; import java.util.Collections; @@ -34,40 +35,41 @@ @Transactional public class DiscussionParticipantService { + private static final int MAX_PARTICIPANT = 15; + private final DiscussionParticipantRepository discussionParticipantRepository; private final UserService userService; - private static final int MAX_PARTICIPANT = 15; private final SharedEventService sharedEventService; public void addDiscussionParticipant(Discussion discussion, User user) { - Long offset = discussionParticipantRepository.findMaxOffsetByDiscussionId( + List offsets = discussionParticipantRepository.findOffsetsByDiscussionId( discussion.getId()); - offset += 1; - - if (offset >= MAX_PARTICIPANT) { + if (offsets.size() >= MAX_PARTICIPANT) { throw new ApiException(ErrorCode.DISCUSSION_PARTICIPANT_EXCEED_LIMIT); } - DiscussionParticipant participant; - if (offset == 0) { - participant = DiscussionParticipant.builder() - .discussion(discussion) - .user(user) - .isHost(true) - .userOffset(offset) - .build(); - } else { - participant = DiscussionParticipant.builder() - .discussion(discussion) - .user(user) - .isHost(false) - .userOffset(offset) - .build(); + long offset = 0L; + for (; offset < offsets.size(); ++offset) { + if (offsets.get((int) offset) != offset) + break; } + + boolean isHost = (offset == 0); + DiscussionParticipant participant = DiscussionParticipant.builder() + .discussion(discussion) + .user(user) + .isHost(isHost) + .userOffset(offset) + .build(); + discussionParticipantRepository.save(participant); } + public void deleteDiscussionParticipants(Long discussionId) { + discussionParticipantRepository.deleteAllByDiscussionId(discussionId); + } + @Transactional(readOnly = true) public List getUsersByDiscussionId(Long discussionId) { return discussionParticipantRepository.findUsersByDiscussionId(discussionId); @@ -80,6 +82,17 @@ public Long getDiscussionParticipantOffset(Long discussionId, Long userId) { .orElseThrow(() -> new ApiException(ErrorCode.DISCUSSION_PARTICIPANT_NOT_FOUND)); } + @Transactional(readOnly = true) + public Map getDiscussionParticipantOffsets(Long discussionId) { + List participants = discussionParticipantRepository.findByDiscussionIdWithUser( + discussionId); + return participants.stream() + .collect(Collectors.toMap( + DiscussionParticipant::getUser, + DiscussionParticipant::getUserOffset + )); + } + @Transactional(readOnly = true) public Map getUserOffsetsMap(Long discussionId) { List usersWithOffset = discussionParticipantRepository.findUserIdNameDtosWithOffset( @@ -309,4 +322,8 @@ public Map> getOngoingDiscussionOffsetsByUserIds( ) ); } + + public void deleteDiscussionParticipant(Long discussionId, Long userId) { + discussionParticipantRepository.deleteByDiscussionIdAndUserId(discussionId, userId); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionRepository.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionRepository.java index 0810003c..79020dbd 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionRepository.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionRepository.java @@ -2,8 +2,13 @@ import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.domain.discussion.enums.DiscussionStatus; +import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.LockModeType; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository @@ -11,4 +16,8 @@ public interface DiscussionRepository extends JpaRepository { List findByDiscussionStatusNot(DiscussionStatus discussionStatus); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM Discussion d WHERE d.id = :discussionId") + Optional findByIdForUpdate(@Param("discussionId") Long discussionId); + } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java index b1e228d5..93414a4b 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java @@ -1,16 +1,8 @@ package endolphin.backend.domain.discussion; -import endolphin.backend.domain.discussion.dto.CandidateEventDetailsRequest; -import endolphin.backend.domain.discussion.dto.CandidateEventDetailsResponse; -import endolphin.backend.domain.discussion.dto.CreateDiscussionRequest; -import endolphin.backend.domain.discussion.dto.DiscussionInfo; -import endolphin.backend.domain.discussion.dto.DiscussionResponse; -import endolphin.backend.domain.discussion.dto.FinishedDiscussionsResponse; -import endolphin.backend.domain.discussion.dto.JoinDiscussionRequest; -import endolphin.backend.domain.discussion.dto.InvitationInfo; -import endolphin.backend.domain.discussion.dto.JoinDiscussionResponse; -import endolphin.backend.domain.discussion.dto.OngoingDiscussionsResponse; +import endolphin.backend.domain.discussion.dto.*; import endolphin.backend.domain.discussion.entity.Discussion; +import endolphin.backend.domain.discussion.entity.DiscussionParticipant; import endolphin.backend.domain.discussion.enums.AttendType; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.personal_event.PersonalEventService; @@ -93,8 +85,7 @@ public DiscussionResponse createDiscussion(CreateDiscussionRequest request) { public SharedEventWithDiscussionInfoResponse confirmSchedule(Long discussionId, SharedEventRequest request) { - Discussion discussion = discussionRepository.findById(discussionId) - .orElseThrow(() -> new ApiException(ErrorCode.DISCUSSION_NOT_FOUND)); + Discussion discussion = getDiscussionById(discussionId); Validator.validateInRange(discussion, request.startDateTime(), request.endDateTime()); Validator.validateDuration(request.startDateTime(), request.endDateTime(), @@ -125,7 +116,7 @@ public SharedEventWithDiscussionInfoResponse confirmSchedule(Long discussionId, discussionRepository.save(discussion); - discussionBitmapService.deleteDiscussionBitmapsUsingScan(discussionId) + discussionBitmapService.deleteDiscussionBitmapsAsync(discussionId) .thenRun(() -> log.info("Redis keys deleted successfully for discussionId : {}", discussionId)) .exceptionally(ex -> { @@ -144,8 +135,7 @@ public SharedEventWithDiscussionInfoResponse confirmSchedule(Long discussionId, @Transactional(readOnly = true) public SharedEventWithDiscussionInfoResponse getSharedEventInfo(Long discussionId) { - Discussion discussion = discussionRepository.findById(discussionId) - .orElseThrow(() -> new ApiException(ErrorCode.DISCUSSION_NOT_FOUND)); + Discussion discussion = getDiscussionById(discussionId); SharedEventDto sharedEventDto = sharedEventService.getSharedEvent(discussionId); @@ -189,6 +179,7 @@ public DiscussionInfo getDiscussionInfo(Long discussionId) { public InvitationInfo getInvitationInfo(Long discussionId) { Discussion discussion = getDiscussionById(discussionId); + User currentUser = userService.getCurrentUser(); return new InvitationInfo( discussionParticipantService.getHostNameByDiscussionId(discussionId), @@ -199,7 +190,8 @@ public InvitationInfo getInvitationInfo(Long discussionId) { discussion.getTimeRangeEnd(), discussion.getDuration(), discussionParticipantService.isFull(discussionId), - discussion.getPassword() != null + discussion.getPassword() != null, + passwordCountService.getExpirationTime(currentUser.getId(), discussionId) ); } @@ -287,8 +279,7 @@ public Discussion getDiscussionById(Long discussionId) { } public JoinDiscussionResponse joinDiscussion(Long discussionId, JoinDiscussionRequest request) { - Discussion discussion = discussionRepository.findById(discussionId) - .orElseThrow(() -> new ApiException(ErrorCode.DISCUSSION_NOT_FOUND)); + Discussion discussion = getDiscussionByIdWithLock(discussionId); if (discussion.getDiscussionStatus() != DiscussionStatus.ONGOING) { throw new ApiException(ErrorCode.DISCUSSION_NOT_ONGOING); @@ -302,8 +293,10 @@ public JoinDiscussionResponse joinDiscussion(Long discussionId, JoinDiscussionRe discussionParticipantService.checkAlreadyParticipated(discussionId, currentUser.getId()); - if (discussion.getPassword() != null && !checkPassword(discussion, request.password())) { - int failedCount = passwordCountService.increaseCount(currentUser.getId(), discussionId); + int failedCount = passwordCountService.tryEnter(currentUser.getId(), discussion, + request.password()); + + if (failedCount != 0) { return new JoinDiscussionResponse(false, failedCount); } @@ -311,7 +304,46 @@ public JoinDiscussionResponse joinDiscussion(Long discussionId, JoinDiscussionRe personalEventService.preprocessPersonalEvents(currentUser, discussion); - return new JoinDiscussionResponse(true, 0); + return new JoinDiscussionResponse(true, failedCount); + } + + public void restartDiscussion(Long discussionId) { + Discussion discussion = getDiscussionById(discussionId); + + if (discussion.getDiscussionStatus() != DiscussionStatus.UPCOMING) { + throw new ApiException(ErrorCode.DISCUSSION_NOT_UPCOMING); + } + + if (!discussionParticipantService.amIHost(discussionId)) { + throw new ApiException(ErrorCode.NOT_ALLOWED_USER); + } + + sharedEventService.deleteSharedEvent(discussionId); + + personalEventService.deletePersonalEventsByDiscussionId(discussionId); + personalEventService.restorePersonalEvents(discussion); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + if (discussion.getDeadline().isBefore(TimeUtil.getToday())) { + discussion.setDeadline(TimeUtil.getNewDeadLine(discussion.getDateRangeEnd())); + } + discussionRepository.save(discussion); + } + + @Transactional(readOnly = true) + public UpcomingDetailResponse getUpcomingEventDetails(Long discussionId) { + Discussion discussion = getDiscussionById(discussionId); + + SharedEventDto sharedEventDto = sharedEventService.getSharedEvent(discussionId); + + CandidateEventDetailsRequest request = new CandidateEventDetailsRequest( + sharedEventDto.startDateTime(), sharedEventDto.endDateTime(), null); + + CandidateEventDetailsResponse eventDetailsResponse = + retrieveCandidateEventDetails(discussionId, request); + + return new UpcomingDetailResponse(discussionId, discussion.getTitle(), + sharedEventDto.startDateTime(), sharedEventDto.endDateTime(), + discussion.getDeadline(), eventDetailsResponse.participants()); } private List getSortedUserInfoWithPersonalEvents( @@ -337,11 +369,70 @@ private List getSortedUserInfoWithPersonalEvents( return sortedResult; } - private boolean checkPassword(Discussion discussion, String password) { - if (password == null || password.isBlank()) { - throw new ApiException(ErrorCode.PASSWORD_REQUIRED); + public DiscussionResponse updateDiscussion(Long discussionId, UpdateDiscussionRequest request) { + Discussion discussion = getDiscussionById(discussionId); + + if (!discussionParticipantService.amIHost(discussionId)) { + throw new ApiException(ErrorCode.NOT_ALLOWED_USER); + } + + if (isTimeChanged(discussion, request)) { + discussionBitmapService.deleteDiscussionBitmaps(discussionId); + personalEventService.restorePersonalEvents(discussion); + } + + discussion.update(request); + discussion = discussionRepository.save(discussion); + + return new DiscussionResponse( + discussion.getId(), + discussion.getTitle(), + discussion.getDateRangeStart(), + discussion.getDateRangeEnd(), + discussion.getMeetingMethod(), + discussion.getLocation(), + discussion.getDuration(), + TimeUtil.calculateTimeLeft(discussion.getDeadline()) + ); + } + + public void exitDiscussion(Long discussionId, Long userId) { + Discussion discussion = getDiscussionByIdWithLock(discussionId); + + Long offset = discussionParticipantService.getDiscussionParticipantOffset(discussionId, + userId); + + discussionBitmapService.deleteUsersFromDiscussion(discussionId, offset); + + discussionParticipantService.deleteDiscussionParticipant(discussionId, userId); + } + + private Discussion getDiscussionByIdWithLock(Long discussionId) { + return discussionRepository.findByIdForUpdate(discussionId) + .orElseThrow(() -> new ApiException(ErrorCode.DISCUSSION_NOT_FOUND)); + } + + public void deleteDiscussion(Long discussionId) { + Discussion discussion = getDiscussionById(discussionId); + + if (!discussionParticipantService.amIHost(discussionId)) { + throw new ApiException(ErrorCode.NOT_ALLOWED_USER); + } + + if (discussion.getDiscussionStatus() != DiscussionStatus.ONGOING) { + throw new ApiException(ErrorCode.DISCUSSION_NOT_ONGOING); } - return passwordEncoder.matches(discussion.getId(), password, discussion.getPassword()); + discussionBitmapService.deleteDiscussionBitmaps(discussionId); + discussionParticipantService.deleteDiscussionParticipants(discussionId); + discussionRepository.delete(discussion); + } + + private boolean isTimeChanged(Discussion discussion, UpdateDiscussionRequest request) { + return !discussion.getDateRangeStart().equals(request.dateRangeStart()) + || !discussion.getDateRangeEnd().equals(request.dateRangeEnd()) + || !discussion.getTimeRangeStart().equals(request.timeRangeStart()) + || !discussion.getTimeRangeEnd().equals(request.timeRangeEnd()); } + } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java b/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java index d78480ab..2c1c1f11 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java @@ -1,6 +1,7 @@ package endolphin.backend.domain.discussion.dto; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; public record InvitationInfo( @@ -12,7 +13,8 @@ public record InvitationInfo( LocalTime timeRangeEnd, Integer duration, Boolean isFull, - Boolean requirePassword + Boolean requirePassword, + LocalDateTime timeUnlocked ) { } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpcomingDetailResponse.java b/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpcomingDetailResponse.java new file mode 100644 index 00000000..2e8e648b --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpcomingDetailResponse.java @@ -0,0 +1,17 @@ +package endolphin.backend.domain.discussion.dto; + +import endolphin.backend.domain.user.dto.UserInfoWithPersonalEvents; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record UpcomingDetailResponse( + Long discussionId, + String title, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + LocalDate deadline, + List participants +) { +} diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpdateDiscussionRequest.java b/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpdateDiscussionRequest.java new file mode 100644 index 00000000..78c615f3 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/discussion/dto/UpdateDiscussionRequest.java @@ -0,0 +1,25 @@ +package endolphin.backend.domain.discussion.dto; + +import endolphin.backend.domain.discussion.enums.MeetingMethod; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.time.LocalTime; + +public record UpdateDiscussionRequest( + @NotBlank @Size(max = 50) String title, + @NotNull @FutureOrPresent LocalDate dateRangeStart, + @NotNull @FutureOrPresent LocalDate dateRangeEnd, + @NotNull LocalTime timeRangeStart, + @NotNull LocalTime timeRangeEnd, + @NotNull @Min(30) @Max(360) Integer duration, + MeetingMethod meetingMethod, + @Size(max = 50) String location, + @NotNull @FutureOrPresent LocalDate deadline +) { + +} diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/entity/Discussion.java b/backend/src/main/java/endolphin/backend/domain/discussion/entity/Discussion.java index 6dedd8f3..ada10d41 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/entity/Discussion.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/entity/Discussion.java @@ -1,5 +1,6 @@ package endolphin.backend.domain.discussion.entity; +import endolphin.backend.domain.discussion.dto.UpdateDiscussionRequest; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.discussion.enums.MeetingMethod; import endolphin.backend.global.base_entity.BaseTimeEntity; @@ -52,6 +53,7 @@ public class Discussion extends BaseTimeEntity { @Column private String location; + @Setter @Column(nullable = false) private LocalDate deadline; @@ -91,4 +93,16 @@ public String getMeetingMethodOrLocation() { return Optional.ofNullable(location) .orElseGet(() -> meetingMethod != null ? meetingMethod.name() : null); } + + public void update(UpdateDiscussionRequest request) { + this.title = request.title(); + this.dateRangeStart = request.dateRangeStart(); + this.dateRangeEnd = request.dateRangeEnd(); + this.timeRangeStart = request.timeRangeStart(); + this.timeRangeEnd = request.timeRangeEnd(); + this.duration = request.duration(); + this.meetingMethod = request.meetingMethod(); + this.location = request.location(); + this.deadline = request.deadline(); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java index 87a92af1..ab9de0f4 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java @@ -2,8 +2,13 @@ import endolphin.backend.domain.personal_event.dto.PersonalEventRequest; import endolphin.backend.domain.personal_event.dto.PersonalEventResponse; +import endolphin.backend.domain.personal_event.dto.SyncResponse; +import endolphin.backend.domain.personal_event.entity.PersonalEvent; +import endolphin.backend.domain.user.UserService; +import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.dto.ListResponse; import endolphin.backend.global.error.ErrorResponse; +import endolphin.backend.global.util.DeferredResultManager; import io.swagger.v3.oas.annotations.Operation; import endolphin.backend.global.util.URIUtil; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -16,6 +21,7 @@ import jakarta.validation.constraints.NotNull; import java.net.URI; import java.time.LocalDate; + import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -27,6 +33,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; @Tag(name = "Personal Event", description = "개인 일정 관리 API") @RestController @@ -35,6 +42,8 @@ public class PersonalEventController { private final PersonalEventService personalEventService; + private final DeferredResultManager deferredResultManager; + private final UserService userService; @Operation(summary = "개인 일정 조회", description = "사용자의 개인 일정을 주 단위로 조회합니다.") @ApiResponses(value = { @@ -53,8 +62,9 @@ public class PersonalEventController { public ResponseEntity> getPersonalEvents( @Valid @NotNull @RequestParam LocalDate startDate, @Valid @NotNull @RequestParam LocalDate endDate) { - ListResponse response = personalEventService.listPersonalEvents( - startDate, endDate); + ListResponse response = + personalEventService.listPersonalEvents(startDate, endDate); + return ResponseEntity.ok(response); } @@ -118,4 +128,20 @@ public ResponseEntity deletePersonalEvent( personalEventService.deleteWithRequest(personalEventId, syncWithGoogleCalendar); return ResponseEntity.noContent().build(); } + + @Operation(summary = "개인 일정 동기화", description = "개인 일정을 실시간으로 동기화합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "일정 동기화 성공", + content = @Content(schema = @Schema(implementation = SyncResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/sync") + public DeferredResult> poll() { + User user = userService.getCurrentUser(); + + return deferredResultManager.create(user); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessor.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessor.java index c9f6b41f..fdd8b6f0 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessor.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessor.java @@ -63,6 +63,15 @@ && isTimeRangeOverlapping(discussion, personalEvent)) { } } + public void preprocess(List personalEvents, Discussion discussion) { + Map offsetMap = discussionParticipantService.getDiscussionParticipantOffsets( + discussion.getId()); + + for (PersonalEvent personalEvent : personalEvents) { + convert(personalEvent, discussion, offsetMap.get(personalEvent.getUser()), true); + } + } + public void preprocessOne(PersonalEvent personalEvent, Discussion discussion, User user, boolean value) { Long index = discussionParticipantService.getDiscussionParticipantOffset(discussion.getId(), diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventRepository.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventRepository.java index 7d03d183..f8b28bdc 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventRepository.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventRepository.java @@ -7,9 +7,11 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository public interface PersonalEventRepository extends JpaRepository { @@ -48,4 +50,18 @@ Long countByUserIdAndDateTimeRange( @Param("userId") Long userId, @Param("startDateTime") LocalDateTime start, @Param("endDateTime") LocalDateTime end); + + @Query("SELECT DISTINCT pe FROM PersonalEvent pe " + + "JOIN FETCH pe.user u " + + "JOIN DiscussionParticipant dp ON dp.user = u " + + "JOIN dp.discussion d " + + "WHERE d.id = :discussionId " + + "AND pe.startTime <= :end " + + "AND pe.endTime >= :start") + List findPersonalEventsByDiscussionIdWithDateOverlap( + @Param("discussionId") Long discussionId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + List findByGoogleEventId(String googleEventId); } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java index 55ee1001..0223c7d9 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java @@ -6,6 +6,8 @@ import endolphin.backend.domain.personal_event.dto.PersonalEventRequest; import endolphin.backend.domain.personal_event.dto.PersonalEventResponse; import endolphin.backend.domain.personal_event.dto.PersonalEventWithStatus; +import endolphin.backend.domain.personal_event.dto.SyncPersonalEvent; +import endolphin.backend.domain.personal_event.dto.SyncPersonalEvent.Status; import endolphin.backend.domain.personal_event.entity.PersonalEvent; import endolphin.backend.domain.personal_event.enums.PersonalEventStatus; import endolphin.backend.domain.personal_event.event.DeletePersonalEvent; @@ -18,21 +20,22 @@ import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.google.dto.GoogleEvent; -import endolphin.backend.global.google.enums.GoogleEventStatus; import endolphin.backend.domain.personal_event.event.InsertPersonalEvent; import endolphin.backend.global.util.IdGenerator; import endolphin.backend.global.util.Validator; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; + +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,21 +54,38 @@ public class PersonalEventService { private final CalendarService calendarService; @Transactional(readOnly = true) - public ListResponse listPersonalEvents(LocalDate startDate, - LocalDate endDate) { + public ListResponse listPersonalEvents( + LocalDate startDate, LocalDate endDate) { User user = userService.getCurrentUser(); Validator.validateDateTimeRange(startDate, endDate); List personalEventResponseList = personalEventRepository.findFilteredPersonalEvents( - user, startDate, endDate) - .stream().map(PersonalEventResponse::fromEntity).toList(); + user, startDate, endDate).stream().map(PersonalEventResponse::fromEntity).toList(); + return new ListResponse<>(personalEventResponseList); } public PersonalEventResponse createWithRequest(PersonalEventRequest request) { User user = userService.getCurrentUser(); + List discussions = discussionParticipantService + .getDiscussionsByUserId(user.getId()); + + PersonalEvent personalEvent = createPersonalEvent(request, user, discussions); + + if (request.syncWithGoogleCalendar()) { + String calendarId = calendarService.getCalendarIdByUser(user); + personalEvent.setGoogleEventId(IdGenerator.generateId(user.getId())); + personalEvent.setCalendarId(calendarId); + eventPublisher.publishEvent(new InsertPersonalEvent(List.of(personalEvent))); + } + + return PersonalEventResponse.fromEntity(personalEvent); + } + + private PersonalEvent createPersonalEvent(PersonalEventRequest request, User user, + List discussions) { Validator.validateDateTimeRange(request.startDateTime(), request.endDateTime()); PersonalEvent personalEvent = PersonalEvent.builder() @@ -76,34 +96,22 @@ public PersonalEventResponse createWithRequest(PersonalEventRequest request) { .user(user) .build(); - PersonalEvent result = personalEventRepository.save(personalEvent); - - if (request.syncWithGoogleCalendar()) { - String calendarId = calendarService.getCalendarIdByUser(user); - personalEvent.setGoogleEventId(IdGenerator.generateId(user.getId())); - personalEvent.setCalendarId(calendarId); - eventPublisher.publishEvent(new InsertPersonalEvent(List.of(personalEvent))); - } - - List discussions = discussionParticipantService.getDiscussionsByUserId( - user.getId()); + PersonalEvent savedPersonalEvent = personalEventRepository.save(personalEvent); discussions.forEach(discussion -> { - personalEventPreprocessor.preprocessOne(result, discussion, user, - true); + personalEventPreprocessor.preprocessOne(savedPersonalEvent, discussion, user, true); }); - return PersonalEventResponse.fromEntity(result); + return savedPersonalEvent; } public void createPersonalEventsForParticipants(List participants, - Discussion discussion, - SharedEventDto sharedEvent) { + Discussion discussion, SharedEventDto sharedEvent) { List userIds = participants.stream().map(User::getId).toList(); Map calendarIdMap = calendarService.getCalendarIdByUsers(userIds); List events = participants.stream() .map(participant -> { - String googleEventId = IdGenerator.generateId(participant.getId()); + String googleEventId = IdGenerator.generateSharedEventId(discussion.getId()); return PersonalEvent.builder() .title(discussion.getTitle()) .startTime(sharedEvent.startDateTime()) @@ -130,26 +138,37 @@ public PersonalEventResponse updateWithRequest(PersonalEventRequest request, validatePersonalEventUser(personalEvent, user); + boolean syncInsert = syncInsert(personalEvent, request); + boolean syncUpdate = syncUpdate(personalEvent, request); + List discussions = discussionParticipantService.getDiscussionsByUserId( user.getId()); PersonalEvent result = updatePersonalEvent(request, personalEvent, user, discussions); - if (request.syncWithGoogleCalendar()) { - if (result.getGoogleEventId() == null && result.getCalendarId() == null) { - String calendarId = calendarService.getCalendarIdByUser(user); - result.update(IdGenerator.generateId(user.getId()), calendarId); - eventPublisher.publishEvent(new InsertPersonalEvent(List.of(result))); - } else { - if (haveToSync(personalEvent, request)) { - eventPublisher.publishEvent(new UpdatePersonalEvent(result)); - } - } + if (syncInsert) { + String calendarId = calendarService.getCalendarIdByUser(user); + result.update(IdGenerator.generateId(user.getId()), calendarId); + eventPublisher.publishEvent(new InsertPersonalEvent(List.of(result))); + } else if (syncUpdate) { + eventPublisher.publishEvent(new UpdatePersonalEvent(result)); } return PersonalEventResponse.fromEntity(result); } + private boolean syncUpdate(PersonalEvent personalEvent, PersonalEventRequest request) { + return request.syncWithGoogleCalendar() + && (isDateTimeChanged(personalEvent, request) + || !StringUtils.equals(personalEvent.getTitle(), request.title())); + } + + private boolean syncInsert(PersonalEvent personalEvent, PersonalEventRequest request) { + return request.syncWithGoogleCalendar() + && personalEvent.getGoogleEventId() == null + && personalEvent.getCalendarId() == null; + } + private PersonalEvent updatePersonalEvent(PersonalEventRequest request, PersonalEvent personalEvent, User user, List discussions) { @@ -158,10 +177,10 @@ private PersonalEvent updatePersonalEvent(PersonalEventRequest request, personalEvent.update(request); - if (isChanged(personalEvent, request)) { + if (isDateTimeChanged(personalEvent, request)) { discussions.forEach(discussion -> { - personalEventPreprocessor. - preprocessOne(oldEvent, discussion, user, false); + personalEventPreprocessor + .preprocessOne(oldEvent, discussion, user, false); personalEventPreprocessor .preprocessOne(personalEvent, discussion, user, true); }); @@ -208,24 +227,55 @@ public void preprocessPersonalEvents(User user, Discussion discussion) { personalEventPreprocessor.preprocess(personalEvents, discussion, user); } - public Set syncWithGoogleEvents(List googleEvents, User user, + public List syncWithGoogleEvents(List googleEvents, User user, String googleCalendarId) { List discussions = discussionParticipantService.getDiscussionsByUserId( user.getId()); - Set changedDates = new HashSet<>(); + + List syncEvents = new ArrayList<>(); + for (GoogleEvent googleEvent : googleEvents) { log.info("Processing Google event: {}", googleEvent); - if (googleEvent.status().equals(GoogleEventStatus.CONFIRMED)) { - upsertPersonalEventByGoogleEvent(googleEvent, discussions, user, googleCalendarId, - changedDates); - changedDates.add(googleEvent.startDateTime().toLocalDate()); - changedDates.add(googleEvent.endDateTime().toLocalDate()); - } else if (googleEvent.status().equals(GoogleEventStatus.CANCELLED)) { - deletePersonalEventByGoogleEvent(googleEvent, discussions, user, googleCalendarId, - changedDates); - } + Optional opt = + syncPersonalEventFromGoogleEvent(user, googleCalendarId, googleEvent, discussions); + + opt.ifPresent(syncEvents::add); } - return changedDates; + + return syncEvents; + } + + private Optional syncPersonalEventFromGoogleEvent( + User user, String googleCalendarId, GoogleEvent googleEvent, List discussions) { + + return switch (googleEvent.status()) { + case CONFIRMED -> { + Optional personalEventOpt = personalEventRepository + .findByGoogleEventIdAndCalendarId(googleEvent.eventId(), googleCalendarId); + + if (personalEventOpt.isPresent()) { + PersonalEvent personalEvent = personalEventOpt.get(); + PersonalEvent updatedPersonalEvent = updatePersonalEvent( + PersonalEventRequest.of(googleEvent, personalEvent.getIsAdjustable()), + personalEvent, user, discussions); + + yield Optional.of(SyncPersonalEvent.from(updatedPersonalEvent, Status.UPDATED)); + } + + PersonalEvent personalEvent = createPersonalEvent( + PersonalEventRequest.of(googleEvent, false), user, discussions); + + yield Optional.of(SyncPersonalEvent.from(personalEvent, Status.CREATED)); + } + case CANCELLED -> { + Optional opt = deletePersonalEventByGoogleEvent( + googleEvent, discussions, user, googleCalendarId); + + yield opt.map( + personalEvent -> SyncPersonalEvent.from(personalEvent, Status.DELETED)); + } + case TENTATIVE -> Optional.empty(); + }; } private void validatePersonalEventUser(PersonalEvent personalEvent, User user) { @@ -234,53 +284,22 @@ private void validatePersonalEventUser(PersonalEvent personalEvent, User user) { } } - private void upsertPersonalEventByGoogleEvent(GoogleEvent googleEvent, - List discussions, User user, String googleCalendarId, - Set changedDates) { - log.info("Upserting personal event by Google event: {}", googleEvent); - personalEventRepository.findByGoogleEventIdAndCalendarId(googleEvent.eventId(), - googleCalendarId) - .ifPresentOrElse(personalEvent -> { - changedDates.add(personalEvent.getStartTime().toLocalDate()); - changedDates.add(personalEvent.getEndTime().toLocalDate()); - updatePersonalEvent(PersonalEventRequest.of(googleEvent), personalEvent, user, - discussions); - }, - () -> { - PersonalEvent personalEvent = - PersonalEvent.fromGoogleEvent(googleEvent, user, googleCalendarId); - personalEventRepository.save(personalEvent); - // 비트맵 수정 - discussions.forEach(discussion -> { - personalEventPreprocessor.preprocessOne(personalEvent, discussion, user, - true); - }); - }); - } - - private void deletePersonalEventByGoogleEvent(GoogleEvent googleEvent, - List discussions, User user, String googleCalendarId, - Set changedDates) { + private Optional deletePersonalEventByGoogleEvent(GoogleEvent googleEvent, + List discussions, User user, String googleCalendarId) { log.info("Deleting personal event by Google event: {}", googleEvent); - personalEventRepository.findByGoogleEventIdAndCalendarId(googleEvent.eventId(), - googleCalendarId) - .ifPresent(personalEvent -> { - changedDates.add(personalEvent.getStartTime().toLocalDate()); - changedDates.add(personalEvent.getEndTime().toLocalDate()); - deletePersonalEvent(personalEvent, user, discussions); - }); + Optional opt = personalEventRepository + .findByGoogleEventIdAndCalendarId(googleEvent.eventId(), googleCalendarId); + + opt.ifPresent(personalEvent -> deletePersonalEvent(personalEvent, user, discussions)); + + return opt; } - private boolean isChanged(PersonalEvent personalEvent, PersonalEventRequest newEvent) { + private boolean isDateTimeChanged(PersonalEvent personalEvent, PersonalEventRequest newEvent) { return !personalEvent.getStartTime().equals(newEvent.startDateTime()) || !personalEvent.getEndTime().equals(newEvent.endDateTime()); } - private boolean haveToSync(PersonalEvent personalEvent, PersonalEventRequest newEvent) { - return isChanged(personalEvent, newEvent) || !personalEvent.getTitle() - .equals(newEvent.title()); - } - @Transactional(readOnly = true) public List findUserInfoWithPersonalEventsByUsers( List users, LocalDateTime searchStartTime, LocalDateTime searchEndTime, @@ -314,4 +333,25 @@ public List findUserInfoWithPersonalEventsByUsers( Comparator.comparing(UserInfoWithPersonalEvents::requirementOfAdjustment).reversed()); return result; } + + public void restorePersonalEvents(Discussion discussion) { + List personalEvents = personalEventRepository.findPersonalEventsByDiscussionIdWithDateOverlap( + discussion.getId(), + discussion.getDateRangeStart().atTime(discussion.getTimeRangeStart()), + discussion.getDateRangeEnd().atTime(discussion.getTimeRangeEnd())); + + personalEventPreprocessor.preprocess(personalEvents, discussion); + } + + public void deletePersonalEventsByDiscussionId(Long discussionId) { + String sharedEventId = IdGenerator.generateSharedEventId(discussionId); + List events = personalEventRepository.findByGoogleEventId(sharedEventId); + if (!events.isEmpty()) { + for (PersonalEvent event : events) { + eventPublisher.publishEvent(new DeletePersonalEvent(event)); + } + + personalEventRepository.deleteAll(events); + } + } } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java index b3a02d47..f69a3fa2 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java @@ -1,5 +1,6 @@ package endolphin.backend.domain.personal_event.dto; +import endolphin.backend.domain.personal_event.entity.PersonalEvent; import endolphin.backend.global.google.dto.GoogleEvent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -14,12 +15,12 @@ public record PersonalEventRequest( Boolean syncWithGoogleCalendar ) { - public static PersonalEventRequest of(GoogleEvent googleEvent) { + public static PersonalEventRequest of(GoogleEvent googleEvent, boolean isAdjustable) { return new PersonalEventRequest( googleEvent.summary(), googleEvent.startDateTime(), googleEvent.endDateTime(), - false, + isAdjustable, false ); } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java new file mode 100644 index 00000000..e94b6c64 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java @@ -0,0 +1,26 @@ +package endolphin.backend.domain.personal_event.dto; + +import endolphin.backend.domain.personal_event.entity.PersonalEvent; +import endolphin.backend.global.google.dto.GoogleEvent; +import endolphin.backend.global.google.enums.GoogleEventStatus; +import java.time.LocalDateTime; + +public record SyncPersonalEvent( + Long id, + Boolean isAdjustable, + String calendarId, + String title, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Status status +) { + + public static SyncPersonalEvent from(PersonalEvent event, Status status) { + return new SyncPersonalEvent(event.getId(), event.getIsAdjustable(), event.getCalendarId(), + event.getTitle(), event.getStartTime(), event.getEndTime(), status); + } + + public enum Status { + CREATED, UPDATED, DELETED + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java new file mode 100644 index 00000000..d605e5a8 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java @@ -0,0 +1,20 @@ +package endolphin.backend.domain.personal_event.dto; + +import java.util.List; + +public record SyncResponse( + List events, + String type +) { + public static SyncResponse timeout() { + return new SyncResponse(null, "timeout"); + } + + public static SyncResponse sync(List events) { + return new SyncResponse(events, "sync"); + } + + public static SyncResponse replaced() { + return new SyncResponse(null, "replaced"); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/entity/PersonalEvent.java b/backend/src/main/java/endolphin/backend/domain/personal_event/entity/PersonalEvent.java index abd0ee2f..0670e4cb 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/entity/PersonalEvent.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/entity/PersonalEvent.java @@ -87,17 +87,4 @@ public PersonalEvent copy() { .user(this.user) .build(); } - - public static PersonalEvent fromGoogleEvent(GoogleEvent googleEvent, User user, - String googleCalenderId) { - return PersonalEvent.builder() - .title(googleEvent.summary()) - .startTime(googleEvent.startDateTime()) - .endTime(googleEvent.endDateTime()) - .googleEventId(googleEvent.eventId()) - .isAdjustable(false) - .calendarId(googleCalenderId) - .user(user) - .build(); - } } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java b/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java index 4e36c80d..b6cdde24 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java @@ -1,13 +1,13 @@ package endolphin.backend.domain.personal_event.event.handler; import endolphin.backend.domain.personal_event.PersonalEventService; +import endolphin.backend.domain.personal_event.dto.SyncPersonalEvent; +import endolphin.backend.domain.personal_event.dto.SyncResponse; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.google.dto.GoogleEvent; import endolphin.backend.global.google.event.GoogleEventChanged; -import endolphin.backend.global.sse.SseEmitters; -import java.time.LocalDate; +import endolphin.backend.global.util.DeferredResultManager; import java.util.List; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -19,20 +19,26 @@ public class PersonalEventHandler { private final PersonalEventService personalEventService; - private final SseEmitters sseEmitters; + private final DeferredResultManager deferredResultManager; @EventListener(classes = {GoogleEventChanged.class}) public void sync(GoogleEventChanged event) { log.info("Syncing personal events with Google events"); try { List events = event.events(); + String googleCalendarId = event.googleCalendarId(); User user = event.user(); + log.info("Syncing personal events for user {}", user.getId()); - String googleCalendarId = event.googleCalendarId(); - Set changedDates = personalEventService.syncWithGoogleEvents(events, user, - googleCalendarId); - sseEmitters.sendToUser(user.getId(), changedDates); + List syncPersonalEvents = + personalEventService.syncWithGoogleEvents(events, user, googleCalendarId); + + if (deferredResultManager.hasActiveConnection(user)) { + deferredResultManager.setResult(user, SyncResponse.sync(syncPersonalEvents)); + } + + } catch (Exception e) { log.error("Failed to sync personal events", e); } diff --git a/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventController.java b/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventController.java index 1a2a0640..ce632ea7 100644 --- a/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventController.java +++ b/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventController.java @@ -3,6 +3,7 @@ import endolphin.backend.domain.discussion.DiscussionService; import endolphin.backend.domain.discussion.dto.FinishedDiscussionsResponse; import endolphin.backend.domain.discussion.dto.OngoingDiscussionsResponse; +import endolphin.backend.domain.discussion.dto.UpcomingDetailResponse; import endolphin.backend.domain.discussion.enums.AttendType; import endolphin.backend.domain.shared_event.dto.SharedEventWithDiscussionInfoResponse; import endolphin.backend.global.dto.ListResponse; @@ -16,10 +17,7 @@ import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "Main View", description = "메인 뷰 API") @RestController @@ -28,7 +26,6 @@ public class SharedEventController { private final DiscussionService discussionService; - private final SharedEventService sharedEventService; @Operation(summary = "진행 중인 논의 조회", description = "진행 중인 논의를 조회합니다.") @ApiResponses(value = { @@ -87,4 +84,21 @@ public ResponseEntity> getUp discussionService.getUpcomingDiscussions(); return ResponseEntity.ok(responses); } + + @Operation(summary = "다가오는 일정 상세 정보 조회", description = "다가오는 일정의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "일정 조회 성공", + content = @Content(schema = @Schema(implementation = UpcomingDetailResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/upcoming/{discussionId}") + public ResponseEntity getUpcomingEventDetails(@PathVariable("discussionId") Long discussionId) { + UpcomingDetailResponse response = discussionService.getUpcomingEventDetails(discussionId); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventService.java b/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventService.java index a219c4b7..2c1c69ab 100644 --- a/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventService.java +++ b/backend/src/main/java/endolphin/backend/domain/shared_event/SharedEventService.java @@ -53,10 +53,9 @@ public Map getSharedEventMap(List discussionIds) { )); } - public void deleteSharedEvent(Long sharedEventId) { - if (!sharedEventRepository.existsById(sharedEventId)) { - throw new ApiException(ErrorCode.SHARED_EVENT_NOT_FOUND); - } - sharedEventRepository.deleteById(sharedEventId); + public void deleteSharedEvent(Long discussionId) { + SharedEvent sharedEvent = sharedEventRepository.findByDiscussionId(discussionId) + .orElseThrow(() -> new ApiException(ErrorCode.SHARED_EVENT_NOT_FOUND)); + sharedEventRepository.delete(sharedEvent); } } diff --git a/backend/src/main/java/endolphin/backend/domain/user/UserRepository.java b/backend/src/main/java/endolphin/backend/domain/user/UserRepository.java index 0d1badbf..8b78b4c8 100644 --- a/backend/src/main/java/endolphin/backend/domain/user/UserRepository.java +++ b/backend/src/main/java/endolphin/backend/domain/user/UserRepository.java @@ -14,7 +14,4 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - @Query("SELECT new endolphin.backend.domain.user.dto.UserIdNameDto(u.id, u.name) " + - "FROM User u WHERE u.id IN :userIds") - List findUserIdNameInIds(@Param("userIds") List userIds); } diff --git a/backend/src/main/java/endolphin/backend/domain/user/UserService.java b/backend/src/main/java/endolphin/backend/domain/user/UserService.java index 5e24f219..ef898f42 100644 --- a/backend/src/main/java/endolphin/backend/domain/user/UserService.java +++ b/backend/src/main/java/endolphin/backend/domain/user/UserService.java @@ -1,23 +1,22 @@ package endolphin.backend.domain.user; import endolphin.backend.domain.user.dto.CurrentUserInfo; -import endolphin.backend.domain.user.dto.UserIdNameDto; -import endolphin.backend.domain.user.dto.UsernameRequest; +import endolphin.backend.domain.user.dto.LoginUserDto; import endolphin.backend.domain.user.event.LoginEvent; import endolphin.backend.global.google.dto.GoogleUserInfo; -import endolphin.backend.global.google.dto.GoogleTokens; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.security.UserContext; import endolphin.backend.global.security.UserInfo; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional @@ -31,13 +30,13 @@ public class UserService { public User getCurrentUser() { UserInfo userInfo = UserContext.get(); return userRepository.findById(userInfo.userId()) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); } @Transactional(readOnly = true) public User getUser(Long userId) { return userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); } public void updateAccessToken(User user, String accessToken) { @@ -45,20 +44,38 @@ public void updateAccessToken(User user, String accessToken) { userRepository.save(user); } - public User upsertUser(GoogleUserInfo userInfo, GoogleTokens tokenResponse) { - User user = userRepository.findByEmail(userInfo.email()) - .orElseGet(() -> { - return User.builder() + public User updateTokens(User user, String accessToken, String refreshToken) { + if (user.getAccessToken() != null) { + return user; + } + user.setAccessToken(accessToken); + user.setRefreshToken(refreshToken); + + return userRepository.save(user); + } + + public LoginUserDto upsertUser(GoogleUserInfo userInfo) { + Optional userOpt = userRepository.findByEmail(userInfo.email()); + Boolean isFirstLogin = userOpt.isEmpty(); + User user; + if (isFirstLogin) { + user = User.builder() .email(userInfo.email()) .name(userInfo.name()) .picture(userInfo.picture()) .build(); - }); - user.setAccessToken(tokenResponse.accessToken()); - user.setRefreshToken(tokenResponse.refreshToken()); - user = userRepository.save(user); - eventPublisher.publishEvent(new LoginEvent(user)); - return user; + user = userRepository.save(user); + } else { + user = userOpt.get(); + } + + Boolean isGoogleCalendarConnected = user.getAccessToken() != null; + + if (isGoogleCalendarConnected) { + eventPublisher.publishEvent(new LoginEvent(user)); + } + + return new LoginUserDto(user, isFirstLogin, isGoogleCalendarConnected); } public CurrentUserInfo updateUsername(String username) { @@ -66,4 +83,9 @@ public CurrentUserInfo updateUsername(String username) { user.setName(username); return new CurrentUserInfo(user.getName(), user.getPicture()); } + + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/user/dto/LoginUserDto.java b/backend/src/main/java/endolphin/backend/domain/user/dto/LoginUserDto.java new file mode 100644 index 00000000..a9580b3e --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/user/dto/LoginUserDto.java @@ -0,0 +1,10 @@ +package endolphin.backend.domain.user.dto; + +import endolphin.backend.domain.user.entity.User; + +public record LoginUserDto( + User user, + Boolean isFirstLogin, + Boolean isGoogleCalendarConnected +) { +} diff --git a/backend/src/main/java/endolphin/backend/global/config/CookieConfig.java b/backend/src/main/java/endolphin/backend/global/config/CookieConfig.java new file mode 100644 index 00000000..e2e4a7f5 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/config/CookieConfig.java @@ -0,0 +1,15 @@ +package endolphin.backend.global.config; + +import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CookieConfig { + + @Bean + public CookieSameSiteSupplier cookieSameSiteSupplier() { + return CookieSameSiteSupplier.ofNone(); + } +} + diff --git a/backend/src/main/java/endolphin/backend/global/config/GoogleCalendarUrl.java b/backend/src/main/java/endolphin/backend/global/config/GoogleCalendarUrl.java index abbf2665..325fd332 100644 --- a/backend/src/main/java/endolphin/backend/global/config/GoogleCalendarUrl.java +++ b/backend/src/main/java/endolphin/backend/global/config/GoogleCalendarUrl.java @@ -2,6 +2,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + @ConfigurationProperties(prefix = "google.calendar.url") public record GoogleCalendarUrl( String calendarListUrl, @@ -13,4 +17,29 @@ public record GoogleCalendarUrl( String updateUrl ) { + public String getEventsUrl(String calendarId) { + return eventsUrl.replace("{calendarId}", calendarId); + } + + public String getUpdateUrl(String calendarId, String eventId) { + return String.format(updateUrl, calendarId, eventId); + } + + public String getSubscribeUrl(String calendarId) { + return subscribeUrl.replace("{calendarId}", calendarId); + } + + public String getSyncUrl(String calendarId, String syncToken, String timeZone) { + StringBuilder sb = new StringBuilder(getEventsUrl(calendarId)); + sb.append("?timeZone=").append(timeZone); + if (syncToken == null || syncToken.isEmpty()) { + String timeMin = LocalDateTime.now().atZone(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_INSTANT); + sb.append("&timeMin=").append(timeMin); + } + else { + sb.append("&syncToken=").append(syncToken); + } + return sb.toString(); + } } diff --git a/backend/src/main/java/endolphin/backend/global/config/GoogleOAuthProperties.java b/backend/src/main/java/endolphin/backend/global/config/GoogleOAuthProperties.java index b28eecce..8a2b925d 100644 --- a/backend/src/main/java/endolphin/backend/global/config/GoogleOAuthProperties.java +++ b/backend/src/main/java/endolphin/backend/global/config/GoogleOAuthProperties.java @@ -4,8 +4,9 @@ @ConfigurationProperties(prefix = "google.oauth") public record GoogleOAuthProperties(String clientId, String clientSecret, - String redirectUri, String scope, + String loginRedirectUri, String loginScope, String authUrl, String tokenUrl, - String userInfoUrl) { + String userInfoUrl, String calendarRedirectUri, + String calendarScope) { } diff --git a/backend/src/main/java/endolphin/backend/global/config/RefreshTokenCookieProperties.java b/backend/src/main/java/endolphin/backend/global/config/RefreshTokenCookieProperties.java new file mode 100644 index 00000000..2a789920 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/config/RefreshTokenCookieProperties.java @@ -0,0 +1,14 @@ +package endolphin.backend.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt.refresh.cookie") +public record RefreshTokenCookieProperties( + String name, + String path, + boolean secure, + boolean httpOnly, + int maxAge +) { + +} diff --git a/backend/src/main/java/endolphin/backend/global/error/GlobalExceptionHandler.java b/backend/src/main/java/endolphin/backend/global/error/GlobalExceptionHandler.java index 3244f206..a869c63c 100644 --- a/backend/src/main/java/endolphin/backend/global/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/endolphin/backend/global/error/GlobalExceptionHandler.java @@ -4,7 +4,11 @@ import endolphin.backend.global.error.exception.CalendarException; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.error.exception.OAuthException; +import endolphin.backend.global.error.exception.RefreshTokenException; +import endolphin.backend.global.util.CookieUtil; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -15,9 +19,12 @@ import org.springframework.web.method.annotation.HandlerMethodValidationException; @RestControllerAdvice +@RequiredArgsConstructor @Slf4j public class GlobalExceptionHandler { + private final CookieUtil cookieUtil; + @ExceptionHandler(ApiException.class) public ResponseEntity handleApiException(ApiException e) { log.error("[API exception] Error code: {}, Message: {}", @@ -50,6 +57,16 @@ public ResponseEntity handleBadRequestExceptions(Exception e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + @ExceptionHandler(RefreshTokenException.class) + public ResponseEntity handleRefreshTokenException(RefreshTokenException e, + HttpServletResponse response) { + log.error("[Refresh Token exception] Error code: {}, Message: {}", + e.getErrorCode(), e.getMessage(), e); + response.addCookie(cookieUtil.deleteRefreshTokenCookie()); + ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode()); + return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(errorResponse); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpectedException(Exception e) { log.error("[Unexpected exception] ", e); diff --git a/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java b/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java index ef2a8054..a0fdeaed 100644 --- a/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java +++ b/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java @@ -16,8 +16,9 @@ public enum ErrorCode { // Discussion DISCUSSION_NOT_FOUND(HttpStatus.NOT_FOUND, "D001", "Discussion not found"), DISCUSSION_NOT_ONGOING(HttpStatus.BAD_REQUEST, "D003", "Discussion not ongoing"), - TOO_MANY_FAILED_ATTEMPTS(HttpStatus.FORBIDDEN, "D004", "Too many failed attempts"), + TOO_MANY_FAILED_ATTEMPTS(HttpStatus.TOO_MANY_REQUESTS, "D004", "Too many failed attempts"), PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "D005", "Password required"), + DISCUSSION_NOT_UPCOMING(HttpStatus.BAD_REQUEST, "D006", "Discussion not upcoming"), // PersonalEvent PERSONAL_EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "Personal Event not found"), @@ -59,6 +60,12 @@ public enum ErrorCode { GC_FORBIDDEN_ERROR(HttpStatus.FORBIDDEN, "GC005", "Forbidden"), GC_GONE_ERROR(HttpStatus.GONE, "GC006", "이미 삭제된 이벤트입니다."), GC_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GC007", "Internal Server Error"), + + // RefreshToken + EMPTY_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "RT001", "Empty Refresh Token"), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "RT002", "Invalid Refresh Token"), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "RT003", "Refresh Token Expired"), + ; private final HttpStatus httpStatus; private final String code; diff --git a/backend/src/main/java/endolphin/backend/global/error/exception/RefreshTokenException.java b/backend/src/main/java/endolphin/backend/global/error/exception/RefreshTokenException.java new file mode 100644 index 00000000..b2ea93b6 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/error/exception/RefreshTokenException.java @@ -0,0 +1,14 @@ +package endolphin.backend.global.error.exception; + +import lombok.Getter; + +@Getter +public class RefreshTokenException extends RuntimeException { + + private final ErrorCode errorCode; + + public RefreshTokenException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarApi.java b/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarApi.java new file mode 100644 index 00000000..ed599c2d --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarApi.java @@ -0,0 +1,155 @@ +package endolphin.backend.global.google; + +import endolphin.backend.global.error.exception.CalendarException; +import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.error.exception.OAuthException; +import endolphin.backend.global.google.dto.GoogleCalendarDto; +import endolphin.backend.global.google.dto.GoogleEventResponse; +import endolphin.backend.global.google.dto.WatchResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GoogleCalendarApi { + private final RestClient restClient; + + private static final String BEARER = "Bearer "; + + public boolean insertEvent(String url, Object body, String accessToken) { + restClient.post() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return Optional.empty(); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + log.info("[insertPersonalEventToGoogleCalendar] success: "); + + return true; + } + + public boolean updateEvent(String url, Object body, String accessToken) { + restClient.put() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return Optional.empty(); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + log.info("[updatePersonalEventToGoogleCalendar] success: "); + + return true; + } + + public boolean deleteEvent(String url, String accessToken) { + restClient.delete() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return Optional.empty(); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + if (response.getStatusCode().isSameCodeAs(HttpStatus.GONE)) { + throw new CalendarException(ErrorCode.GC_GONE_ERROR, + response.bodyTo(String.class)); + } + + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + log.info("[deletePersonalEventFromGoogleCalender] success: "); + + return true; + } + + public GoogleEventResponse syncEvents(String uri, String accessToken) { + return restClient.get() + .uri(uri) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return response.bodyTo(GoogleEventResponse.class); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + if (response.getStatusCode().isSameCodeAs(HttpStatus.GONE)) { + throw new CalendarException(ErrorCode.GC_EXPIRED_SYNC_TOKEN, + response.bodyTo(String.class)); + } + + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + } + + public GoogleCalendarDto getPrimaryCalendar(String url, String accessToken) { + return restClient.get() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return response.bodyTo(GoogleCalendarDto.class); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + } + + public WatchResponse watchEvents(String url, Object body, String accessToken) { + return restClient.post() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, BEARER + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((request, response) -> { + if (response.getStatusCode().is2xxSuccessful()) { + return response.bodyTo(WatchResponse.class); + } + log.error("Invalid request: {}", response.bodyTo(String.class)); + if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); + } + throw new CalendarException((HttpStatus) response.getStatusCode(), + response.bodyTo(String.class)); + }); + } +} diff --git a/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarService.java b/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarService.java index 762477cf..4dd951fc 100644 --- a/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarService.java +++ b/backend/src/main/java/endolphin/backend/global/google/GoogleCalendarService.java @@ -9,7 +9,6 @@ import endolphin.backend.global.config.GoogleCalendarUrl; import endolphin.backend.global.error.exception.CalendarException; import endolphin.backend.global.error.exception.ErrorCode; -import endolphin.backend.global.error.exception.OAuthException; import endolphin.backend.global.google.dto.EventItem; import endolphin.backend.global.google.dto.EventTime; import endolphin.backend.global.google.dto.GoogleCalendarDto; @@ -21,55 +20,43 @@ import endolphin.backend.global.google.enums.GoogleEventStatus; import endolphin.backend.global.google.enums.GoogleResourceState; import endolphin.backend.global.google.event.GoogleEventChanged; +import endolphin.backend.global.google.event.SyncCalendarNotificationEvent; import endolphin.backend.global.util.RetryExecutor; import endolphin.backend.global.util.TimeUtil; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestClient; import java.util.List; import java.util.UUID; @Service @RequiredArgsConstructor -@Transactional @Slf4j public class GoogleCalendarService { - private final RestClient restClient; + private final GoogleCalendarProperties googleCalendarProperties; private final GoogleCalendarUrl googleCalendarUrl; + private final GoogleCalendarApi googleCalendarApi; + private final CalendarService calendarService; private final UserService userService; - private final GoogleCalendarProperties googleCalendarProperties; - private final ApplicationEventPublisher eventPublisher; private final RetryExecutor retryExecutor; + private final ApplicationEventPublisher eventPublisher; - public void upsertGoogleCalendar(User user) { - if (calendarService.isExistingCalendar(user.getId())) { - Calendar calendar = calendarService.getCalendarByUserId(user.getId()); - subscribeToCalendar(calendar, user); - } else { - GoogleCalendarDto googleCalendarDto = getPrimaryCalendar( - user); - Calendar calendar = calendarService.createCalendar(googleCalendarDto, user); - getCalendarEvents(googleCalendarDto.id(), user); - subscribeToCalendar(calendar, user); - } + public void subscribeGoogleCalendar(User user) { + Calendar calendar = calendarService.getCalendarByUserId(user.getId()); + subscribeToCalendar(calendar, user); + syncWithCalendar(calendar.getCalendarId(), user); } public void insertPersonalEvents(List personalEvents) { @@ -79,179 +66,64 @@ public void insertPersonalEvents(List personalEvents) { } public void insertPersonalEventToGoogleCalendar(PersonalEvent personalEvent) { - String eventUrl = googleCalendarUrl.eventsUrl() - .replace("{calendarId}", personalEvent.getCalendarId()); + String eventUrl = googleCalendarUrl.getEventsUrl(personalEvent.getCalendarId()); User user = personalEvent.getUser(); + if (isAccessTokenAbsent(user)) + return; + GoogleEventRequest body = GoogleEventRequest.of(personalEvent, personalEvent.getGoogleEventId()); retryExecutor.executeCalendarApiWithRetry( () -> { - restClient.post() - .uri(eventUrl) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + user.getAccessToken()) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .body(body) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return Optional.empty(); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); - log.info("[insertPersonalEventToGoogleCalendar] success: "); - - return true; + return googleCalendarApi.insertEvent(eventUrl, body, user.getAccessToken()); }, user, personalEvent.getCalendarId() ); } public void updatePersonalEventToGoogleCalendar(PersonalEvent personalEvent) { - String eventUrl = String.format(googleCalendarUrl.updateUrl(), - personalEvent.getCalendarId(), personalEvent.getGoogleEventId()); + String eventUrl = googleCalendarUrl.getUpdateUrl(personalEvent.getCalendarId(), personalEvent.getGoogleEventId()); User user = personalEvent.getUser(); - String token = user.getAccessToken(); + + if (isAccessTokenAbsent(user)) + return; GoogleEventRequest body = GoogleEventRequest.of(personalEvent, personalEvent.getGoogleEventId()); retryExecutor.executeCalendarApiWithRetry( () -> { - restClient.put() - .uri(eventUrl) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .body(body) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return Optional.empty(); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); - log.info("[updatePersonalEventToGoogleCalendar] success: "); - - return true; + return googleCalendarApi.updateEvent(eventUrl, body, user.getAccessToken()); }, user, personalEvent.getCalendarId() ); } public void deletePersonalEventFromGoogleCalender(PersonalEvent personalEvent) { - String eventUrl = String.format(googleCalendarUrl.updateUrl(), - personalEvent.getCalendarId(), personalEvent.getGoogleEventId()); + String eventUrl = googleCalendarUrl.getUpdateUrl(personalEvent.getCalendarId(), personalEvent.getGoogleEventId()); User user = personalEvent.getUser(); - String token = user.getAccessToken(); - - retryExecutor.executeCalendarApiWithRetry( - () -> { - restClient.delete() - .uri(eventUrl) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return Optional.empty(); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - if (response.getStatusCode().isSameCodeAs(HttpStatus.GONE)) { - throw new CalendarException(ErrorCode.GC_GONE_ERROR, - response.bodyTo(String.class)); - } - - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); - log.info("[deletePersonalEventFromGoogleCalender] success: "); - return true; - }, user, personalEvent.getCalendarId() - ); - } - - public void getCalendarEvents(String calendarId, User user) { - String eventsUrl = googleCalendarUrl.eventsUrl().replace("{calendarId}", calendarId); - - String timeMin = LocalDateTime.now().atZone(ZoneOffset.UTC) - .format(DateTimeFormatter.ISO_INSTANT); - - StringBuilder sb = new StringBuilder(eventsUrl); - sb.append("?timeMin=").append(timeMin); - sb.append("&timeZone=").append(googleCalendarProperties.timeZone()); + if (isAccessTokenAbsent(user)) + return; retryExecutor.executeCalendarApiWithRetry( () -> { - GoogleEventResponse result = restClient.get() - .uri(sb.toString()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + user.getAccessToken()) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return response.bodyTo(GoogleEventResponse.class); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); - log.info("[getCalendarEvents] success: {}", result); - List events = extractEventList(result); - - extractSyncToken(calendarId, result); - - eventPublisher.publishEvent(new GoogleEventChanged(events, user, calendarId)); - return true; - }, user, calendarId + return googleCalendarApi.deleteEvent(eventUrl, user.getAccessToken()); + }, user, personalEvent.getCalendarId() ); } public void syncWithCalendar(String calendarId, User user) { String syncToken = calendarService.getSyncToken(calendarId); - String eventsUrl = googleCalendarUrl.eventsUrl().replace("{calendarId}", calendarId); + String eventsUrl = googleCalendarUrl.getSyncUrl(calendarId, syncToken, googleCalendarProperties.timeZone()); - if (syncToken == null || syncToken.isEmpty()) { - getCalendarEvents(calendarId, user); - } - StringBuilder sb = new StringBuilder(eventsUrl); - sb.append("?syncToken=").append(syncToken); - sb.append("&timeZone=").append(googleCalendarProperties.timeZone()); log.info("[syncWithCalendar] syncing user {} with token: {}", user.getName(), syncToken); retryExecutor.executeCalendarApiWithRetry( () -> { - GoogleEventResponse result = restClient.get() - .uri(sb.toString()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + user.getAccessToken()) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return response.bodyTo(GoogleEventResponse.class); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - if (response.getStatusCode().isSameCodeAs(HttpStatus.GONE)) { - throw new CalendarException(ErrorCode.GC_EXPIRED_SYNC_TOKEN, - response.bodyTo(String.class)); - } - - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); + GoogleEventResponse result = googleCalendarApi.syncEvents(eventsUrl, user.getAccessToken()); List events = extractEventList(result); + extractSyncToken(calendarId, result); log.info("[syncWithCalendar] before publish event, calId: {}, userId: {}", calendarId, user.getId()); eventPublisher.publishEvent(new GoogleEventChanged(events, user, calendarId)); @@ -261,24 +133,12 @@ public void syncWithCalendar(String calendarId, User user) { } public GoogleCalendarDto getPrimaryCalendar(User user) { - String accessToken = user.getAccessToken(); + String eventsUrl = googleCalendarUrl.primaryCalendarUrl(); return retryExecutor.executeCalendarApiWithRetry( () -> { - GoogleCalendarDto result = restClient.get() - .uri(googleCalendarUrl.primaryCalendarUrl()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return response.bodyTo(GoogleCalendarDto.class); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); + GoogleCalendarDto result = + googleCalendarApi.getPrimaryCalendar(eventsUrl, user.getAccessToken()); log.info("[getPrimaryCalendar] success: {}", result); return result; }, user, null @@ -290,8 +150,7 @@ public void subscribeToCalendar(Calendar calendar, User user) { .isBefore(TimeUtil.getNow())) { return; } - String subscribeUrl = googleCalendarUrl.subscribeUrl() - .replace("{calendarId}", calendar.getCalendarId()); + String subscribeUrl = googleCalendarUrl.getSubscribeUrl(calendar.getCalendarId()); WatchRequest body = WatchRequest.of(UUID.randomUUID().toString(), googleCalendarUrl.webhookUrl(), @@ -300,24 +159,8 @@ public void subscribeToCalendar(Calendar calendar, User user) { retryExecutor.executeCalendarApiWithRetry( () -> { - WatchResponse result = restClient.post() - .uri(subscribeUrl) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + user.getAccessToken()) - .contentType(MediaType.APPLICATION_JSON) - .body(body) - .exchange((request, response) -> { - if (response.getStatusCode().is2xxSuccessful()) { - return response.bodyTo(WatchResponse.class); - } - log.error("Invalid request: {}", response.bodyTo(String.class)); - if (response.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { - throw new OAuthException(ErrorCode.OAUTH_UNAUTHORIZED_ERROR); - } - throw new CalendarException((HttpStatus) response.getStatusCode(), - response.bodyTo(String.class)); - }); + WatchResponse result = googleCalendarApi.watchEvents(subscribeUrl, body, user.getAccessToken()); log.info("[subscribeToCalendar] success: {}", result); - return true; }, user, calendar.getCalendarId() ); @@ -343,8 +186,8 @@ public ResponseEntity processWebhookNotification( log.info("🔄 [SYNC] Resource ID: {}, Channel ID: {}, User ID: {}, expiration : {}", resourceId, channelId, userId, channelExpiration); - calendarService.setWebhookProperties(calendarId, resourceId, channelId, - channelExpiration); + eventPublisher.publishEvent(new SyncCalendarNotificationEvent(calendarId, resourceId, channelId, + channelExpiration)); } else if (GoogleResourceState.EXISTS.getValue().equals(resourceState)) { log.info("📅 [EXISTS] Calendar ID: {}, User ID: {}", calendarId, userId); @@ -367,6 +210,11 @@ public ResponseEntity processWebhookNotification( } } + private boolean isAccessTokenAbsent(User user) { + String accessToken = user.getAccessToken(); + return accessToken == null || accessToken.isEmpty(); + } + private Long parseUserId(String channelToken) { try { return Long.parseLong(channelToken); diff --git a/backend/src/main/java/endolphin/backend/global/google/GoogleOAuthService.java b/backend/src/main/java/endolphin/backend/global/google/GoogleOAuthService.java index 076290b0..64d95f77 100644 --- a/backend/src/main/java/endolphin/backend/global/google/GoogleOAuthService.java +++ b/backend/src/main/java/endolphin/backend/global/google/GoogleOAuthService.java @@ -24,12 +24,18 @@ public class GoogleOAuthService { private final GoogleOAuthProperties googleOAuthProperties; private final RestClient restClient; - public GoogleTokens getGoogleTokens(String code) { + public GoogleTokens getGoogleTokens(String code, boolean isLogin) { MultiValueMap params = getStringStringMultiValueMap(); params.add("code", code); params.add("grant_type", "authorization_code"); params.add("access_type", "offline"); + if (isLogin) { + params.add("redirect_uri", googleOAuthProperties.loginRedirectUri()); + } else { + params.add("redirect_uri", googleOAuthProperties.calendarRedirectUri()); + } + return restClient.post() .uri(googleOAuthProperties.tokenUrl()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) @@ -71,7 +77,6 @@ private MultiValueMap getStringStringMultiValueMap() { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("client_id", googleOAuthProperties.clientId()); params.add("client_secret", googleOAuthProperties.clientSecret()); - params.add("redirect_uri", googleOAuthProperties.redirectUri()); return params; } diff --git a/backend/src/main/java/endolphin/backend/global/google/event/SyncCalendarNotificationEvent.java b/backend/src/main/java/endolphin/backend/global/google/event/SyncCalendarNotificationEvent.java new file mode 100644 index 00000000..349fab0e --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/google/event/SyncCalendarNotificationEvent.java @@ -0,0 +1,9 @@ +package endolphin.backend.global.google.event; + +public record SyncCalendarNotificationEvent( + String calendarId, + String resourceId, + String channelId, + String channelExpiration +) { +} diff --git a/backend/src/main/java/endolphin/backend/global/google/event/handler/GoogleEventHandler.java b/backend/src/main/java/endolphin/backend/global/google/event/handler/GoogleEventHandler.java index 28cc8af3..6436ebf1 100644 --- a/backend/src/main/java/endolphin/backend/global/google/event/handler/GoogleEventHandler.java +++ b/backend/src/main/java/endolphin/backend/global/google/event/handler/GoogleEventHandler.java @@ -1,5 +1,6 @@ package endolphin.backend.global.google.event.handler; +import endolphin.backend.domain.calendar.event.GoogleCalendarLinkEvent; import endolphin.backend.domain.personal_event.entity.PersonalEvent; import endolphin.backend.domain.personal_event.event.DeletePersonalEvent; import endolphin.backend.domain.personal_event.event.UpdatePersonalEvent; @@ -9,6 +10,7 @@ import endolphin.backend.domain.personal_event.event.InsertPersonalEvent; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @@ -29,9 +31,16 @@ public void insert(InsertPersonalEvent event) { @Async @TransactionalEventListener(classes = {LoginEvent.class}) - public void login(LoginEvent event) { + public void link(LoginEvent event) { User user = event.user(); - googleCalendarService.upsertGoogleCalendar(user); + googleCalendarService.subscribeGoogleCalendar(user); + } + + @Async + @EventListener(classes = {GoogleCalendarLinkEvent.class}) + public void link(GoogleCalendarLinkEvent event) { + User user = event.user(); + googleCalendarService.subscribeGoogleCalendar(user); } @Async diff --git a/backend/src/main/java/endolphin/backend/global/redis/DiscussionBitmapService.java b/backend/src/main/java/endolphin/backend/global/redis/DiscussionBitmapService.java index 2379496e..444aebcb 100644 --- a/backend/src/main/java/endolphin/backend/global/redis/DiscussionBitmapService.java +++ b/backend/src/main/java/endolphin/backend/global/redis/DiscussionBitmapService.java @@ -1,17 +1,26 @@ package endolphin.backend.global.redis; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.RedisCommands; import org.springframework.data.redis.connection.RedisCommandsProvider; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisKeyCommands; +import org.springframework.data.redis.connection.RedisSetCommands; import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisCommand; +import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.SessionCallback; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -96,26 +105,13 @@ public byte[] getBitmapData(Long discussionId, Long minuteKey) { * @param discussionId 논의 식별자 */ @Async - public CompletableFuture deleteDiscussionBitmapsUsingScan(Long discussionId) { - String pattern = discussionId + ":*"; - ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(1000).build(); - + public CompletableFuture deleteDiscussionBitmapsAsync(Long discussionId) { int retryCount = 0; int maxRetries = 3; while (retryCount < maxRetries) { try { - redisTemplate.execute((RedisConnection connection) -> { - RedisKeyCommands keyCommands = ((RedisCommandsProvider) connection).keyCommands(); - - try (Cursor cursor = keyCommands.scan(scanOptions)) { - while (cursor.hasNext()) { - byte[] rawKey = cursor.next(); - keyCommands.del(rawKey); - } - } - return null; - }); + deleteDiscussionBitmaps(discussionId); return CompletableFuture.completedFuture(null); } catch (Exception ex) { retryCount++; @@ -135,6 +131,23 @@ public CompletableFuture deleteDiscussionBitmapsUsingScan(Long discussionI return CompletableFuture.completedFuture(null); } + public void deleteDiscussionBitmaps(Long discussionId) { + String pattern = discussionId + ":*"; + ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(1000).build(); + + redisTemplate.execute((RedisConnection connection) -> { + RedisKeyCommands keyCommands = ((RedisCommandsProvider) connection).keyCommands(); + + try (Cursor cursor = keyCommands.scan(scanOptions)) { + while (cursor.hasNext()) { + byte[] rawKey = cursor.next(); + keyCommands.del(rawKey); + } + } + return null; + }); + } + public Map getDataOfDiscussionId(Long discussionId) { String pattern = discussionId + ":*"; ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(1000).build(); @@ -164,4 +177,36 @@ public Map getDataOfDiscussionId(Long discussionId) { return map; }); } + + public void deleteUsersFromDiscussion(Long discussionId, long bitOffset) { + String pattern = discussionId + ":*"; + ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(1000).build(); + + List keys = redisTemplate.execute((RedisConnection connection) -> { + List k = new ArrayList<>(); + RedisKeyCommands keyCommands = connection.keyCommands(); + try (Cursor cursor = keyCommands.scan(scanOptions)) { + while (cursor.hasNext()) { + k.add(cursor.next()); + } + } + return k; + }); + + if (keys == null || keys.isEmpty()) { + return; + } + + redisTemplate.executePipelined(new RedisCallback() { + + @Override + public Object doInRedis(RedisConnection connection) throws DataAccessException { + RedisCommands redisCommands = connection.commands(); + for (byte[] key : keys) { + redisCommands.setBit(key, bitOffset, false); + } + return null; + } + }); + } } diff --git a/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java b/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java index a74fb4db..283d0671 100644 --- a/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java +++ b/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java @@ -1,7 +1,11 @@ package endolphin.backend.global.redis; +import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.security.PasswordEncoder; +import endolphin.backend.global.util.TimeUtil; +import java.time.LocalDateTime; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; @@ -12,20 +16,24 @@ public class PasswordCountService { private final StringRedisTemplate redisStringTemplate; + private final PasswordEncoder passwordEncoder; - private static final int MAX_FAILED_ATTEMPTS = 5; + public static final int MAX_FAILED_ATTEMPTS = 5; private static final long LOCKOUT_DURATION_MS = 5 * 60 * 1000; - public int increaseCount(Long userId, Long discussionId) { - String redisKey = "failedAttempts:" + discussionId + ":" + userId; + public int tryEnter(Long userId, Discussion discussion, String password) { + String redisKey = "failedAttempts:" + discussion.getId() + ":" + userId; - String countStr = redisStringTemplate.opsForValue().get(redisKey); - int failedAttemptsCount = countStr != null ? Integer.parseInt(countStr) : 0; + int failedAttemptsCount = getFailedCount(redisKey); if (failedAttemptsCount >= MAX_FAILED_ATTEMPTS) { throw new ApiException(ErrorCode.TOO_MANY_FAILED_ATTEMPTS); } + if (discussion.getPassword() == null || checkPassword(discussion, password)) { + return 0; + } + Long updatedCount = redisStringTemplate.opsForValue().increment(redisKey); if (updatedCount == null) { @@ -35,4 +43,29 @@ public int increaseCount(Long userId, Long discussionId) { return updatedCount.intValue(); } + + public LocalDateTime getExpirationTime(Long userId, Long discussionId) { + String redisKey = "failedAttempts:" + discussionId + ":" + userId; + + if (getFailedCount(redisKey) < MAX_FAILED_ATTEMPTS) { + return null; + } + + long remainingTime = redisStringTemplate.getExpire(redisKey, TimeUnit.SECONDS); + + return TimeUtil.getNow().plusSeconds(remainingTime); + } + + public int getFailedCount(String redisKey) { + String countStr = redisStringTemplate.opsForValue().get(redisKey); + return countStr != null ? Integer.parseInt(countStr) : 0; + } + + private boolean checkPassword(Discussion discussion, String password) { + if (password == null || password.isBlank()) { + throw new ApiException(ErrorCode.PASSWORD_REQUIRED); + } + + return passwordEncoder.matches(discussion.getId(), password, discussion.getPassword()); + } } diff --git a/backend/src/main/java/endolphin/backend/global/scheduler/DiscussionStatusScheduler.java b/backend/src/main/java/endolphin/backend/global/scheduler/DiscussionStatusScheduler.java index 946b79f6..dc2e3e98 100644 --- a/backend/src/main/java/endolphin/backend/global/scheduler/DiscussionStatusScheduler.java +++ b/backend/src/main/java/endolphin/backend/global/scheduler/DiscussionStatusScheduler.java @@ -1,23 +1,22 @@ package endolphin.backend.global.scheduler; -import static endolphin.backend.global.util.TimeUtil.TIME_ZONE; import endolphin.backend.domain.discussion.DiscussionRepository; import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.shared_event.SharedEventService; import endolphin.backend.global.redis.DiscussionBitmapService; +import endolphin.backend.global.util.TimeUtil; import java.time.LocalDate; -import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Component +@Service @RequiredArgsConstructor @Slf4j public class DiscussionStatusScheduler { @@ -27,9 +26,9 @@ public class DiscussionStatusScheduler { private final DiscussionBitmapService discussionBitmapService; @Transactional - @Scheduled(cron = "0 0 0 * * *", zone = TIME_ZONE) + @Scheduled(cron = "0 0 0 * * *", zone = TimeUtil.TIME_ZONE) public void updateDiscussionStatusAtMidnight() { - LocalDate today = LocalDate.now(ZoneId.of(TIME_ZONE)); + LocalDate today = TimeUtil.getToday(); log.info("Scheduler 실행: {}. 논의 상태 업데이트 시작", today); List discussions = discussionRepository.findByDiscussionStatusNot( @@ -59,7 +58,7 @@ public Discussion updateStatus(Discussion discussion, LocalDate today) { discussion.setFixedDate(discussion.getDateRangeEnd()); log.info("ONGOING Discussion id {} FINISHED", discussion.getId()); - discussionBitmapService.deleteDiscussionBitmapsUsingScan(discussion.getId()) + discussionBitmapService.deleteDiscussionBitmapsAsync(discussion.getId()) .thenRun( () -> log.info("Redis keys deleted successfully for discussionId : {}", discussion.getId())) diff --git a/backend/src/main/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpScheduler.java b/backend/src/main/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpScheduler.java new file mode 100644 index 00000000..e601c730 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpScheduler.java @@ -0,0 +1,27 @@ +package endolphin.backend.global.scheduler; + +import endolphin.backend.domain.auth.RefreshTokenRepository; +import endolphin.backend.global.util.TimeUtil; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenCleanUpScheduler { + + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + @Scheduled(cron = "0 0 0 * * ?", zone = TimeUtil.TIME_ZONE) + public void cleanupExpiredRefreshTokens() { + LocalDateTime now = TimeUtil.getNow(); + refreshTokenRepository.deleteByExpirationBefore(now); + log.info("Expired refresh tokens deleted at {}", now); + } +} + diff --git a/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java b/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java index cb582eac..0e2ddf82 100644 --- a/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java +++ b/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java @@ -62,17 +62,16 @@ protected boolean shouldNotFilter(HttpServletRequest request) { List excludedPaths = List.of( "/api/v1/login", "/api/v1/oauth2/callback", + "/api/v1/calendar/callback/google", "/api/v1/google/webhook", + "/api/v1/refresh-token", "/swagger-ui", // prod profile에서는 swagger-ui 접근 불가 "/v3/api-docs", // prod profile에서는 v3/api-docs 접근 불가 "/h2-console", // prod profile에서는 h2-console 접근 불가 "/health" ); - Pattern invitePattern = Pattern.compile("^/api/v1/discussion/\\d+/invite$"); - return "OPTIONS".equalsIgnoreCase(request.getMethod()) || - excludedPaths.stream().anyMatch(path::startsWith) || - invitePattern.matcher(path).matches(); + excludedPaths.stream().anyMatch(path::startsWith); } } diff --git a/backend/src/main/java/endolphin/backend/global/security/JwtProvider.java b/backend/src/main/java/endolphin/backend/global/security/JwtProvider.java index 1a1b1534..b3950ea4 100644 --- a/backend/src/main/java/endolphin/backend/global/security/JwtProvider.java +++ b/backend/src/main/java/endolphin/backend/global/security/JwtProvider.java @@ -1,5 +1,6 @@ package endolphin.backend.global.security; +import endolphin.backend.domain.auth.dto.TokenDto; import endolphin.backend.global.util.TimeUtil; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; @@ -16,6 +17,8 @@ public class JwtProvider { @Value("${jwt.expired}") private long validityInMs; + @Value("${jwt.refresh.expired}") + private long refreshValidityInMs; private final SecretKey key; public JwtProvider(@Value("${jwt.secret}") String secretKey) { @@ -23,21 +26,37 @@ public JwtProvider(@Value("${jwt.secret}") String secretKey) { } // 토큰 발행 - public String createToken(Long userId, String email) { + public TokenDto createToken(Long userId, String email) { LocalDateTime now = TimeUtil.getNow(); Date nowDate = Date.from(now.atZone(ZoneId.of(TimeUtil.TIME_ZONE)).toInstant()); Date expiry = new Date(nowDate.getTime() + validityInMs); - return Jwts.builder() + String token = Jwts.builder() .claim("userId", userId) .claim("email", email) .issuedAt(nowDate) .expiration(expiry) .signWith(key) .compact(); + + return new TokenDto(token, TimeUtil.convertToLocalDateTime(expiry)); + } + + public TokenDto createToken(Long userId) { + LocalDateTime now = TimeUtil.getNow(); + Date nowDate = Date.from(now.atZone(ZoneId.of(TimeUtil.TIME_ZONE)).toInstant()); + Date expiry = new Date(nowDate.getTime() + refreshValidityInMs); + + String token = Jwts.builder() + .claim("userId", userId) + .issuedAt(nowDate) + .expiration(expiry) + .signWith(key) + .compact(); + + return new TokenDto(token, TimeUtil.convertToLocalDateTime(expiry)); } - // 검증, 파싱 public Jws validateToken(String token) { try { return Jwts.parser() @@ -48,4 +67,8 @@ public Jws validateToken(String token) { throw new RuntimeException("Invalid or expired JWT token"); } } + + public Long getUserId(String token) { + return validateToken(token).getPayload().get("userId", Long.class); + } } diff --git a/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java b/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java deleted file mode 100644 index 74210429..00000000 --- a/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java +++ /dev/null @@ -1,28 +0,0 @@ -package endolphin.backend.global.sse; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDate; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Tag(name = "SSE", description = "Server-Sent Events") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/sse/events") -public class CalendarEventController { - - private final SseEmitters emitters; - - @Operation(summary = "구독", description = "사용자의 캘린더 이벤트를 구독합니다.") - @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter subscribe(@RequestParam("userId") Long userId) { - return emitters.add(userId); - } -} \ No newline at end of file diff --git a/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java b/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java deleted file mode 100644 index a3dd06d1..00000000 --- a/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java +++ /dev/null @@ -1,49 +0,0 @@ -package endolphin.backend.global.sse; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -@Slf4j -public class SseEmitters { - - private final Map emitters = new ConcurrentHashMap<>(); - private static final Long TIMEOUT = 1000L * 60 * 30; - - public SseEmitter add(Long userId) { - SseEmitter emitter = new SseEmitter(TIMEOUT); - log.info("User {} connected", userId); - - emitter.onCompletion(() -> emitters.remove(userId)); - emitter.onTimeout(() -> emitters.remove(userId)); - - emitters.put(userId, emitter); - - try { - emitter.send(SseEmitter.event().comment("connected")); - log.info("Dummy Data sent to User {}", userId); - } catch (IOException e) { - emitters.remove(userId); - } - - return emitter; - } - - public void sendToUser(Long userId, Object data) { - SseEmitter emitter = emitters.get(userId); - if (emitter != null) { - try { - log.info("Data {} sent to User {}", data, userId); - emitter.send(SseEmitter.event().data(data)); - } catch (IOException e) { - emitters.remove(userId); - } - } - } -} - diff --git a/backend/src/main/java/endolphin/backend/global/util/CookieUtil.java b/backend/src/main/java/endolphin/backend/global/util/CookieUtil.java new file mode 100644 index 00000000..139be5e1 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/util/CookieUtil.java @@ -0,0 +1,51 @@ +package endolphin.backend.global.util; + +import endolphin.backend.domain.auth.dto.TokenDto; +import endolphin.backend.global.config.RefreshTokenCookieProperties; +import jakarta.servlet.http.Cookie; +import java.time.temporal.ChronoUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CookieUtil { + + private final RefreshTokenCookieProperties properties; + + public Cookie createRefreshTokenCookie(TokenDto refreshToken) { + Cookie refreshTokenCookie = new Cookie(properties.name(), refreshToken.token()); + refreshTokenCookie.setHttpOnly(properties.httpOnly()); + refreshTokenCookie.setSecure(properties.secure()); + refreshTokenCookie.setPath(properties.path()); + + int maxAge = (int) ChronoUnit.SECONDS.between(TimeUtil.getNow(), + refreshToken.expiredAt()); + refreshTokenCookie.setMaxAge(maxAge); + + return refreshTokenCookie; + } + + public String getRefreshToken(Cookie[] cookies) { + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (cookie.getName().equals(properties.name())) { + return cookie.getValue(); + } + } + + return null; + } + + public Cookie deleteRefreshTokenCookie() { + Cookie refreshTokenCookie = new Cookie(properties.name(), null); + refreshTokenCookie.setHttpOnly(properties.httpOnly()); + refreshTokenCookie.setSecure(properties.secure()); + refreshTokenCookie.setPath(properties.path()); + refreshTokenCookie.setMaxAge(0); + return refreshTokenCookie; + } +} diff --git a/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java b/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java new file mode 100644 index 00000000..688d8a8c --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java @@ -0,0 +1,53 @@ +package endolphin.backend.global.util; + +import endolphin.backend.domain.personal_event.dto.SyncResponse; +import endolphin.backend.domain.user.entity.User; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.async.DeferredResult; + +@Service +public class DeferredResultManager { + + private final Map>> results = + new ConcurrentHashMap<>(); + private final static Long TIMEOUT = 60000L; + + public DeferredResult> create(User user) { + Long userId = user.getId(); + if (results.containsKey(userId)) { + DeferredResult> result = results.get(userId); + result.setResult(ResponseEntity.ok(SyncResponse.replaced())); + results.remove(userId); + } + DeferredResult> deferredResult = + new DeferredResult<>(TIMEOUT, ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT) + .body(SyncResponse.timeout())); + deferredResult.onCompletion(() -> { + if (results.containsKey(userId) && results.get(userId).isSetOrExpired()) { + results.remove(userId); + } + }); + deferredResult.onTimeout(() -> { + results.remove(userId); + }); + results.put(userId, deferredResult); + return deferredResult; + } + + public void setResult(User user, SyncResponse response) { + DeferredResult> deferredResult = + results.get(user.getId()); + if (deferredResult != null && !deferredResult.isSetOrExpired()) { + deferredResult.setResult(ResponseEntity.ok(response)); + } + } + + public boolean hasActiveConnection(User user) { + DeferredResult> result = results.get(user.getId()); + return result != null && !result.isSetOrExpired(); + } +} diff --git a/backend/src/main/java/endolphin/backend/global/util/IdGenerator.java b/backend/src/main/java/endolphin/backend/global/util/IdGenerator.java index aac63314..cfdd51df 100644 --- a/backend/src/main/java/endolphin/backend/global/util/IdGenerator.java +++ b/backend/src/main/java/endolphin/backend/global/util/IdGenerator.java @@ -6,6 +6,7 @@ public class IdGenerator { private static final String PREFIX = "endolphin"; + private static final String PREFIX_SHARED_EVENT = "shared"; private static final Base32 base = new Base32(true); public static String generateId(Long id) { @@ -14,4 +15,11 @@ public static String generateId(Long id) { base.encodeToString(UUID.randomUUID().toString().getBytes()) .replace("=", "").toLowerCase()); } + + public static String generateSharedEventId(Long discussionId) { + return String.format("%s%s%s", + PREFIX, PREFIX_SHARED_EVENT, + base.encodeToString(discussionId.toString().getBytes()) + .replace("=", "").toLowerCase()); + } } diff --git a/backend/src/main/java/endolphin/backend/global/util/RetryExecutor.java b/backend/src/main/java/endolphin/backend/global/util/RetryExecutor.java index d3b6f772..045721db 100644 --- a/backend/src/main/java/endolphin/backend/global/util/RetryExecutor.java +++ b/backend/src/main/java/endolphin/backend/global/util/RetryExecutor.java @@ -66,7 +66,7 @@ public T executeCalendarApiWithRetry(Supplier action, User user, String c } delay = INITIAL_DELAY_MS * (long) Math.pow(2, attempts); delay += ThreadLocalRandom.current().nextLong(delay); - if (attempts >= MAX_RETRIES - 1) { + if (attempts >= MAX_RETRIES) { log.error("Retry exceeded maximum number of retries: username: {}, calendarId: {}", user.getName(), calendarId); throw new CalendarException(HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/backend/src/main/java/endolphin/backend/global/util/TimeUtil.java b/backend/src/main/java/endolphin/backend/global/util/TimeUtil.java index 40c058a2..9c015eb0 100644 --- a/backend/src/main/java/endolphin/backend/global/util/TimeUtil.java +++ b/backend/src/main/java/endolphin/backend/global/util/TimeUtil.java @@ -6,6 +6,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Date; public class TimeUtil { @@ -98,7 +99,21 @@ public static LocalDateTime convertToLocalDateTime(long minuteKey) { return LocalDateTime.ofEpochSecond(epochSeconds, 0, ZoneOffset.UTC); } + public static LocalDateTime convertToLocalDateTime(Date date) { + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.of(TIME_ZONE)); + } + public static LocalDateTime getNow() { return LocalDateTime.now(ZoneId.of(TIME_ZONE)); } + + public static LocalDate getToday() { + return LocalDate.now(ZoneId.of(TIME_ZONE)); + } + + public static LocalDate getNewDeadLine(LocalDate dateRangeEnd) { + LocalDate todayPlusSeven = getToday().plusDays(7); + return todayPlusSeven.isBefore(dateRangeEnd) ? todayPlusSeven : dateRangeEnd; + } + } diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index 93153ab0..3bc7a641 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -2,10 +2,10 @@ + value="%d{yyyy-MM-dd HH:mm:ss}:%-4relative %highlight(%-5level) %magenta(${PID:-}) [%thread] %cyan(%logger{36}) %X{correlationId} - %msg%n"/> - + value="%d{yyyy-MM-dd HH:mm:ss}:%-4relative %-5level ${PID:-} [%thread] %logger{36} %X{correlationId} - %msg%n"/> + @@ -15,18 +15,18 @@ - + - - - + + + - ${LOG_PATH}/myapp-%d{yyyy-MM-dd}.%i.log + ${LOG_PATH}/endolphin-%d{yyyy-MM-dd}.%i.log 30 10MB 500MB @@ -38,20 +38,36 @@ - + 512 - 0 + 0 false true + + + ${LOG_PATH}/searchingSpeed-%d{yyyy-MM-dd}.%i.log + 30 + 10MB + 500MB + + + + ${FILE_LOG_PATTERN} + + + - + - - - + + + + + + \ No newline at end of file diff --git a/backend/src/test/java/endolphin/backend/domain/auth/AuthServiceTest.java b/backend/src/test/java/endolphin/backend/domain/auth/AuthServiceTest.java index 4709c799..1b06a2b1 100644 --- a/backend/src/test/java/endolphin/backend/domain/auth/AuthServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/auth/AuthServiceTest.java @@ -2,20 +2,21 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; import endolphin.backend.domain.auth.dto.OAuthResponse; +import endolphin.backend.domain.auth.dto.AuthTokenResponse; +import endolphin.backend.domain.auth.dto.TokenDto; import endolphin.backend.domain.user.UserService; +import endolphin.backend.domain.user.dto.LoginUserDto; import endolphin.backend.domain.user.entity.User; -import endolphin.backend.global.google.GoogleCalendarService; import endolphin.backend.global.google.GoogleOAuthService; import endolphin.backend.global.google.dto.GoogleTokens; import endolphin.backend.global.google.dto.GoogleUserInfo; -import endolphin.backend.global.security.JwtProvider; - import java.time.LocalDateTime; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,57 +31,97 @@ class AuthServiceTest { @Mock private UserService userService; - @Mock - private GoogleCalendarService googleCalendarService; - @Mock private GoogleOAuthService googleOAuthService; @Mock - private JwtProvider jwtProvider; + private TokenService tokenService; @InjectMocks private AuthService authService; - @BeforeEach - public void setUp() { - ReflectionTestUtils.setField(authService, "expired", 10000); - } - @Test @DisplayName("로그인 테스트 - 캘린더 연동 포함") void loginTest() { // Given String code = "test-auth-code"; GoogleTokens googleTokens = new GoogleTokens("test-access-token", "test-refresh-token"); - GoogleUserInfo googleUserInfo = new GoogleUserInfo("test-sub", "test-name", "test-email", "test-pic"); + GoogleUserInfo googleUserInfo = new GoogleUserInfo("test-sub", "test-name", "test-email", + "test-pic"); User user = User.builder() .email(googleUserInfo.email()) .name(googleUserInfo.name()) .picture(googleUserInfo.picture()) - .accessToken(googleTokens.accessToken()) - .refreshToken(googleTokens.refreshToken()) .build(); + ReflectionTestUtils.setField(user, "id", 1L); - given(googleOAuthService.getGoogleTokens(anyString())) - .willReturn(googleTokens); + LoginUserDto loginUserDto = new LoginUserDto(user, false, false); + given(googleOAuthService.getGoogleTokens(anyString(), anyBoolean())) + .willReturn(googleTokens); given(googleOAuthService.getUserInfo(anyString())) .willReturn(googleUserInfo); + given(userService.upsertUser(any(GoogleUserInfo.class))) + .willReturn(loginUserDto); - given(userService.upsertUser(any(GoogleUserInfo.class), any(GoogleTokens.class))) - .willReturn(user); + LocalDateTime accessExp = LocalDateTime.now().plusSeconds(100); + LocalDateTime refreshExp = LocalDateTime.now().plusSeconds(200); + + TokenDto accessTokenDto = new TokenDto("test-jwt-token", accessExp); + TokenDto refreshTokenDto = new TokenDto("test-refresh-token", refreshExp); + + when(tokenService.createAccessToken(user)) + .thenReturn(accessTokenDto); + when(tokenService.createRefreshToken(user)) + .thenReturn(refreshTokenDto); - given(jwtProvider.createToken(user.getId(), user.getEmail())) - .willReturn("test-jwt-token"); // When - OAuthResponse response = authService.login(code); + AuthTokenResponse authTokenResponse = authService.login(code); + OAuthResponse response = authTokenResponse.oAuthResponse(); // Then assertThat(response.accessToken()).isEqualTo("test-jwt-token"); assertThat(response.expiredAt()).isNotNull(); assertThat(response.expiredAt()).isAfter(LocalDateTime.now()); + assertThat(authTokenResponse.refreshToken()).isEqualTo(refreshTokenDto); + } + + @Test + @DisplayName("리프레시 토큰 재발급 테스트") + void refreshTest() { + // Given + String refreshToken = "test-refresh-token"; + when(tokenService.getUserIdFromToken(refreshToken)).thenReturn(1L); + + User user = User.builder() + .email("test-email") + .name("test-name") + .picture("test-pic") + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + given(userService.getUser(1L)).willReturn(user); + + LocalDateTime accessExp = LocalDateTime.now().plusSeconds(100); + LocalDateTime refreshExp = LocalDateTime.now().plusSeconds(200); + + TokenDto newAccessTokenDto = new TokenDto("new-test-jwt-token", accessExp); + TokenDto newRefreshTokenDto = new TokenDto("new-test-refresh-token", refreshExp); + + when(tokenService.createAccessToken(user)) + .thenReturn(newAccessTokenDto); + when(tokenService.updateRefreshToken(user.getId(), refreshToken)) + .thenReturn(newRefreshTokenDto); + + // When + AuthTokenResponse authTokenResponse = authService.refresh(refreshToken); + OAuthResponse response = authTokenResponse.oAuthResponse(); + + // Then + assertThat(response.accessToken()).isEqualTo("new-test-jwt-token"); + assertThat(response.expiredAt()).isNotNull(); + assertThat(response.expiredAt()).isAfter(LocalDateTime.now()); + assertThat(authTokenResponse.refreshToken()).isEqualTo(newRefreshTokenDto); } } diff --git a/backend/src/test/java/endolphin/backend/domain/auth/TokenServiceTest.java b/backend/src/test/java/endolphin/backend/domain/auth/TokenServiceTest.java new file mode 100644 index 00000000..c93641b5 --- /dev/null +++ b/backend/src/test/java/endolphin/backend/domain/auth/TokenServiceTest.java @@ -0,0 +1,164 @@ +package endolphin.backend.domain.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import endolphin.backend.domain.auth.dto.TokenDto; +import endolphin.backend.domain.auth.entity.RefreshToken; +import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.error.exception.RefreshTokenException; +import endolphin.backend.global.security.JwtProvider; +import endolphin.backend.global.util.TimeUtil; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +public class TokenServiceTest { + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtProvider jwtProvider; + + @InjectMocks + private TokenService tokenService; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder().email("test@example.com").build(); + ReflectionTestUtils.setField(user, "id", 1L); + } + + @Test + @DisplayName("getUserIdFromToken: 유효한 토큰일 경우 올바른 userId 반환") + void testGetUserIdFromTokenValid() { + String token = "valid-token"; + when(jwtProvider.getUserId(token)).thenReturn(user.getId()); + + Long userId = tokenService.getUserIdFromToken(token); + + assertThat(userId).isEqualTo(user.getId()); + } + + @Test + @DisplayName("getUserIdFromToken: null 또는 빈 토큰일 경우 예외 발생") + void testGetUserIdFromTokenInvalid() { + assertThatThrownBy(() -> tokenService.getUserIdFromToken(null)) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining(ErrorCode.EMPTY_REFRESH_TOKEN.getMessage()); + assertThatThrownBy(() -> tokenService.getUserIdFromToken(" ")) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining(ErrorCode.EMPTY_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("createAccessToken: access token 생성 후 반환") + void testCreateAccessToken() { + LocalDateTime expiry = LocalDateTime.now().plusSeconds(3600); + TokenDto tokenDto = new TokenDto("access-token", expiry); + when(jwtProvider.createToken(user.getId(), user.getEmail())).thenReturn(tokenDto); + + TokenDto result = tokenService.createAccessToken(user); + + assertThat(result).isNotNull(); + assertThat(result.token()).isEqualTo("access-token"); + verify(jwtProvider).createToken(user.getId(), user.getEmail()); + } + + @Test + @DisplayName("createRefreshToken: refresh token 생성 후 저장 및 반환") + void testCreateRefreshToken() { + LocalDateTime expiry = LocalDateTime.now().plusSeconds(7200); + TokenDto tokenDto = new TokenDto("refresh-token", expiry); + when(jwtProvider.createToken(user.getId())).thenReturn(tokenDto); + + TokenDto result = tokenService.createRefreshToken(user); + + assertThat(result).isNotNull(); + assertThat(result.token()).isEqualTo("refresh-token"); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("updateRefreshToken: 토큰 검증 통과 시 기존 토큰을 새 토큰으로 업데이트") + void testUpdateRefreshTokenSuccess() { + String oldToken = "old-refresh-token"; + LocalDateTime newExpiry = TimeUtil.getNow().plusSeconds(7200); + TokenDto newTokenDto = new TokenDto("new-refresh-token", newExpiry); + + RefreshToken refreshToken = mock(RefreshToken.class); + when(refreshToken.getExpiration()).thenReturn(TimeUtil.getNow().plusMinutes(10)); + when(refreshTokenRepository.findByToken(oldToken)).thenReturn(Optional.of(refreshToken)); + when(jwtProvider.createToken(user.getId())).thenReturn(newTokenDto); + + TokenDto result = tokenService.updateRefreshToken(user.getId(), oldToken); + + assertThat(result).isNotNull(); + assertThat(result.token()).isEqualTo("new-refresh-token"); + verify(refreshToken).updateToken(newTokenDto); + verify(refreshTokenRepository).save(refreshToken); + } + + @Test + @DisplayName("updateRefreshToken: 존재하지 않는 토큰일 경우 예외 발생") + void testUpdateRefreshTokenNotFound() { + String oldToken = "non-existent-token"; + when(refreshTokenRepository.findByToken(oldToken)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tokenService.updateRefreshToken(user.getId(), oldToken)) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining(ErrorCode.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("updateRefreshToken: 만료된 토큰일 경우 삭제 후 예외 발생") + void testUpdateRefreshTokenValidationFails() { + String oldToken = "old-refresh-token"; + RefreshToken refreshToken = mock(RefreshToken.class); + when(refreshToken.getExpiration()).thenReturn(LocalDateTime.now().minusSeconds(10)); + when(refreshTokenRepository.findByToken(oldToken)).thenReturn(Optional.of(refreshToken)); + + assertThatThrownBy(() -> tokenService.updateRefreshToken(user.getId(), oldToken)) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining(ErrorCode.REFRESH_TOKEN_EXPIRED.getMessage()); + + verify(refreshTokenRepository).delete(refreshToken); + } + + @Test + @DisplayName("deleteRefreshToken: 존재하는 토큰 삭제") + void testDeleteRefreshToken() { + String token = "delete-token"; + RefreshToken refreshToken = new RefreshToken(); + when(refreshTokenRepository.findByToken(token)).thenReturn(Optional.of(refreshToken)); + + tokenService.deleteRefreshToken(token); + + verify(refreshTokenRepository).delete(refreshToken); + } + + @Test + @DisplayName("deleteRefreshToken: 존재하지 않는 토큰이면 삭제하지 않음") + void testDeleteRefreshTokenNotFound() { + String token = "non-existent-token"; + when(refreshTokenRepository.findByToken(token)).thenReturn(Optional.empty()); + + tokenService.deleteRefreshToken(token); + + verify(refreshTokenRepository, never()).delete(any(RefreshToken.class)); + } +} diff --git a/backend/src/test/java/endolphin/backend/domain/calendar/CalendarServiceTest.java b/backend/src/test/java/endolphin/backend/domain/calendar/CalendarServiceTest.java index 8bcbfeff..67ef16d9 100644 --- a/backend/src/test/java/endolphin/backend/domain/calendar/CalendarServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/calendar/CalendarServiceTest.java @@ -8,9 +8,8 @@ import endolphin.backend.domain.calendar.entity.Calendar; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.error.exception.CalendarException; -import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.google.dto.GoogleCalendarDto; -import java.time.LocalDateTime; + import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,16 +42,6 @@ void setUp() { .build(); } - @Test - @DisplayName("캘린더 생성 테스트") - void createCalendar_ShouldSaveCalendar() { - GoogleCalendarDto googleCalendarDto = new GoogleCalendarDto("calendar-123", "Test Calendar", "owner"); - - calendarService.createCalendar(googleCalendarDto, mockUser); - - verify(calendarRepository, times(1)).save(any(Calendar.class)); - } - @Test @DisplayName("SyncToken 조회 테스트") void getSyncToken_ShouldReturnSyncToken() { diff --git a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantRepositoryTest.java b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantRepositoryTest.java index 1b23d464..6249c349 100644 --- a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantRepositoryTest.java +++ b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantRepositoryTest.java @@ -561,4 +561,16 @@ public void findUpcomingDiscussionsTest() { assertThat(result).isNotNull(); assertThat(result.size()).isEqualTo(2); } + + @DisplayName("fetch join을 사용하여 discussionId로 참여자 엔티티 조회") + @Test + public void testFindByDiscussionIdWithUser() { + List participants = discussionParticipantRepository.findByDiscussionIdWithUser(discussion.getId()); + assertThat(participants).hasSize(3); + for (DiscussionParticipant dp : participants) { + assertThat(dp.getUser()).isNotNull(); + assertThat(dp.getUser().getName()).isIn("Alice", "Bob", "Charlie"); + } + } + } diff --git a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantServiceTest.java b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantServiceTest.java index e263d7d3..6f38b458 100644 --- a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionParticipantServiceTest.java @@ -10,6 +10,7 @@ import endolphin.backend.domain.discussion.dto.OngoingDiscussion; import endolphin.backend.domain.discussion.dto.OngoingDiscussionsResponse; import endolphin.backend.domain.discussion.entity.Discussion; +import endolphin.backend.domain.discussion.entity.DiscussionParticipant; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.shared_event.SharedEventService; import endolphin.backend.domain.shared_event.dto.SharedEventDto; @@ -25,6 +26,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -79,8 +81,8 @@ void addDiscussionParticipant_ShouldAddSuccessfully() { .picture("profile.jpg") .build(); - given(discussionParticipantRepository.findMaxOffsetByDiscussionId(discussion.getId())) - .willReturn(0L); + given(discussionParticipantRepository.findOffsetsByDiscussionId(discussion.getId())) + .willReturn(new ArrayList<>()); // When discussionParticipantService.addDiscussionParticipant(discussion, user); @@ -109,8 +111,8 @@ void addDiscussionParticipant_ShouldThrowExceptionWhenExceedLimit() { .picture("profile.jpg") .build(); - given(discussionParticipantRepository.findMaxOffsetByDiscussionId(discussion.getId())) - .willReturn(15L); // 최대 인원 초과 상황 + given(discussionParticipantRepository.findOffsetsByDiscussionId(discussion.getId())) + .willReturn(List.of(0L, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L, 13L, 14L)); // 최대 인원 초과 상황 // When & Then ApiException exception = assertThrows(ApiException.class, () -> { @@ -608,4 +610,18 @@ public void getUpcomingSharedEvents_ShouldReturnSharedEventsWithDiscussionInfo() assertThat(ded2.sharedEventDto()).isEqualTo(sharedEvent1); assertThat(ded2.participantPictureUrls()).containsExactly("pic1.jpg", "pic2.jpg"); } + + @Test + @DisplayName("논의 참여자 삭제 - 성공") + void deleteDiscussionParticipants_ShouldDeleteSuccessfully() { + // Given + Long discussionId = 1L; + + // When + discussionParticipantService.deleteDiscussionParticipants(discussionId); + + // Then + verify(discussionParticipantRepository).deleteAllByDiscussionId(discussionId); + } + } diff --git a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java index 14476d98..461ab7f5 100644 --- a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java @@ -5,11 +5,13 @@ import static org.assertj.core.api.Assertions.within; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeast; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,7 +22,10 @@ import endolphin.backend.domain.discussion.dto.DiscussionResponse; import endolphin.backend.domain.discussion.dto.InvitationInfo; import endolphin.backend.domain.discussion.dto.JoinDiscussionRequest; +import endolphin.backend.domain.discussion.dto.JoinDiscussionResponse; +import endolphin.backend.domain.discussion.dto.UpdateDiscussionRequest; import endolphin.backend.domain.discussion.entity.Discussion; +import endolphin.backend.domain.discussion.entity.DiscussionParticipant; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.discussion.enums.MeetingMethod; import endolphin.backend.domain.personal_event.PersonalEventService; @@ -170,7 +175,7 @@ public void confirmSchedule_withValidRequest_returnsExpectedResponse() { .thenReturn(dummyParticipants); when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); - when(discussionBitmapService.deleteDiscussionBitmapsUsingScan( + when(discussionBitmapService.deleteDiscussionBitmapsAsync( any(Long.class) )).thenReturn(CompletableFuture.completedFuture(null)); @@ -232,7 +237,7 @@ public void confirmSchedule_asyncFailureHandledGracefully() { .thenReturn(dummyParticipants); when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); - when(discussionBitmapService.deleteDiscussionBitmapsUsingScan(any())) + when(discussionBitmapService.deleteDiscussionBitmapsAsync(any())) .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Redis Error"))); // When @@ -251,7 +256,7 @@ public void confirmSchedule_asyncFailureHandledGracefully() { verify(personalEventService).createPersonalEventsForParticipants( dummyParticipants, discussion, sharedEventDto ); - verify(discussionBitmapService).deleteDiscussionBitmapsUsingScan(discussionId); + verify(discussionBitmapService).deleteDiscussionBitmapsAsync(discussionId); } @DisplayName("논의 확정 시 논의 상태가 ONGOING이 아닌 경우 예외 발생") @@ -377,7 +382,8 @@ public void getDiscussionInfo_returnsExpectedDiscussionInfo() { assertThat(info.duration()).isEqualTo(60); assertThat(info.deadline()).isEqualTo(deadline); - long expectedTimeLeft = Duration.between(LocalDateTime.now(), deadline.atTime(23, 59, 59)).toMillis(); + long expectedTimeLeft = Duration.between(LocalDateTime.now(), deadline.atTime(23, 59, 59)) + .toMillis(); // 약간의 오차(1000ms 이내)는 허용 assertThat(info.timeLeft()).isCloseTo(expectedTimeLeft, within(1000L)); } @@ -410,6 +416,7 @@ public void getInvitationInfo_returnsExpectedInvitationInfo() { when(discussionParticipantService.getHostNameByDiscussionId(discussionId)) .thenReturn("HostName"); when(discussionParticipantService.isFull(discussionId)).thenReturn(true); + when(userService.getCurrentUser()).thenReturn(new User()); InvitationInfo invitationInfo = discussionService.getInvitationInfo(discussionId); @@ -497,7 +504,8 @@ void retrieveCandidateEventDetails_ValidRequest_ReturnsCorrectResponse() { given(discussionParticipantService.getUsersByDiscussionId(eq(discussionId))) .willReturn(participants); given(personalEventService.findUserInfoWithPersonalEventsByUsers( - anyList(), any(LocalDateTime.class), any(LocalDateTime.class), any(LocalDateTime.class), any(LocalDateTime.class), any(Map.class))) + anyList(), any(LocalDateTime.class), any(LocalDateTime.class), any(LocalDateTime.class), + any(LocalDateTime.class), any(Map.class))) .willReturn(personalEvents); // When @@ -519,7 +527,6 @@ void retrieveCandidateEventDetails_ValidRequest_ReturnsCorrectResponse() { assertThat(response.participants().get(1).id()).isEqualTo(participant2.getId()); assertThat(response.participants().get(1).events()).isEmpty(); - // participant1의 이벤트 검증 assertThat(response.participants().get(2).id()).isEqualTo(participant1.getId()); assertThat(response.participants().get(2).events()).isEmpty(); @@ -576,6 +583,7 @@ public void testRetrieveCandidateEventDetails_error_userParticipant() { // 날짜 범위 검증 단계에서 예외가 발생하므로 다른 서비스들은 호출되지 않아야 함 then(personalEventService).shouldHaveNoInteractions(); } + @DisplayName("비밀번호가 일치할 때 논의 참여 성공") @Test public void joinDiscussion_withCorrectPassword_returnsTrue() { @@ -588,23 +596,21 @@ public void joinDiscussion_withCorrectPassword_returnsTrue() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - ReflectionTestUtils.setField(discussion, "id", 1L); - + ReflectionTestUtils.setField(discussion, "id", discussionId); discussion.setPassword(encodedPassword); User currentUser = new User(); ReflectionTestUtils.setField(currentUser, "id", 1L); - when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionRepository.findByIdForUpdate(discussionId)).thenReturn(Optional.of(discussion)); when(userService.getCurrentUser()).thenReturn(currentUser); - when(passwordEncoder.matches(discussionId, correctPassword, encodedPassword)).thenReturn( - true); + when(passwordCountService.tryEnter(currentUser.getId(), discussion, + correctPassword)).thenReturn(0); - // When - boolean result = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(correctPassword)).isSuccess(); + JoinDiscussionResponse response = discussionService.joinDiscussion(discussionId, + new JoinDiscussionRequest(correctPassword)); - // Then - assertThat(result).isTrue(); + assertThat(response.isSuccess()).isTrue(); verify(discussionParticipantService).addDiscussionParticipant(discussion, currentUser); verify(personalEventService).preprocessPersonalEvents(currentUser, discussion); } @@ -621,29 +627,25 @@ public void joinDiscussion_withIncorrectPassword_returnsFalse() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - + ReflectionTestUtils.setField(discussion, "id", discussionId); discussion.setPassword(encodedPassword); - ReflectionTestUtils.setField(discussion, "id", 1L); - User currentUser = new User(); ReflectionTestUtils.setField(currentUser, "id", 1L); - when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionRepository.findByIdForUpdate(discussionId)).thenReturn(Optional.of(discussion)); when(userService.getCurrentUser()).thenReturn(currentUser); - when(passwordEncoder.matches(discussionId, incorrectPassword, encodedPassword)).thenReturn( - false); + when(passwordCountService.tryEnter(currentUser.getId(), discussion, + incorrectPassword)).thenReturn(1); // When - boolean result = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(incorrectPassword)).isSuccess(); + JoinDiscussionResponse response = discussionService.joinDiscussion(discussionId, + new JoinDiscussionRequest(incorrectPassword)); - // Then - assertThat(result).isFalse(); - verify(passwordCountService).increaseCount(currentUser.getId(), discussionId); - verify(discussionParticipantService, org.mockito.Mockito.never()).addDiscussionParticipant( - any(), any()); - verify(personalEventService, org.mockito.Mockito.never()).preprocessPersonalEvents(any(), - any()); + assertThat(response.isSuccess()).isFalse(); + verify(passwordCountService).tryEnter(currentUser.getId(), discussion, incorrectPassword); + verify(discussionParticipantService, never()).addDiscussionParticipant(any(), any()); + verify(personalEventService, never()).preprocessPersonalEvents(any(), any()); } @DisplayName("비밀번호가 없을 때 예외 발생") @@ -655,15 +657,22 @@ public void joinDiscussion_withNullPassword_throwsApiException() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - discussion.setPassword("encodedPassword123"); - when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); - when(userService.getCurrentUser()).thenReturn(new User()); + User currentUser = new User(); + ReflectionTestUtils.setField(currentUser, "id", 1L); + + when(discussionRepository.findByIdForUpdate(discussionId)).thenReturn(Optional.of(discussion)); + when(userService.getCurrentUser()).thenReturn(currentUser); + + // 비밀번호가 null인 경우, passwordCountService.tryEnter가 예외를 던지도록 모킹 + when(passwordCountService.tryEnter(currentUser.getId(), discussion, null)) + .thenThrow(new ApiException(ErrorCode.PASSWORD_REQUIRED)); // When & Then - assertThatThrownBy(() -> discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(null))) - .isInstanceOf(ApiException.class) + assertThatThrownBy(() -> + discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(null)) + ).isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PASSWORD_REQUIRED); } @@ -672,13 +681,302 @@ public void joinDiscussion_withNullPassword_throwsApiException() { public void joinDiscussion_withInvalidDiscussionId_throwsApiException() { // Given Long discussionId = 999L; + when(discussionRepository.findByIdForUpdate(discussionId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> + discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest("password123")) + ).isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DISCUSSION_NOT_FOUND); + } + + @DisplayName("논의 재시작(restartDiscussion) 성공 테스트: UPCOMMING 상태이고 Host인 경우") + @Test + public void restartDiscussion_whenFinishedAndHost_success() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .dateEnd(LocalDate.now().plusDays(6)) + .deadline(LocalDate.now().minusDays(1)) + .build(); + discussion.setDiscussionStatus(DiscussionStatus.UPCOMING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); + + // When + discussionService.restartDiscussion(discussionId); + + // Then + verify(sharedEventService).deleteSharedEvent(discussionId); + verify(personalEventService).restorePersonalEvents(discussion); + assertThat(discussion.getDiscussionStatus()).isEqualTo(DiscussionStatus.ONGOING); + assertThat(discussion.getDeadline()).isEqualTo(LocalDate.now().plusDays(6)); + verify(discussionRepository).save(discussion); + } + + @DisplayName("restartDiscussion 실패 테스트: 논의 상태가 UPCOMMING이 아닌 경우") + @Test + public void restartDiscussion_whenNotFinished_throwsException() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + + // When & Then + assertThatThrownBy(() -> discussionService.restartDiscussion(discussionId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DISCUSSION_NOT_UPCOMING); + + verify(sharedEventService, never()).deleteSharedEvent(any()); + verify(personalEventService, never()).restorePersonalEvents(any()); + verify(discussionRepository, never()).save(any()); + } + + @DisplayName("restartDiscussion 실패 테스트: Host가 아닌 경우") + @Test + public void restartDiscussion_whenNotHost_throwsException() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.UPCOMING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> discussionService.restartDiscussion(discussionId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ALLOWED_USER); + + verify(sharedEventService, never()).deleteSharedEvent(any()); + verify(personalEventService, never()).restorePersonalEvents(any()); + verify(discussionRepository, never()).save(any()); + } + + @DisplayName("restartDiscussion 실패 테스트: 존재하지 않는 논의 ID인 경우") + @Test + public void restartDiscussion_whenDiscussionNotFound_throwsException() { + // Given + Long discussionId = 1L; when(discussionRepository.findById(discussionId)).thenReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest("password123"))) + assertThatThrownBy(() -> discussionService.restartDiscussion(discussionId)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DISCUSSION_NOT_FOUND); + + verify(sharedEventService, never()).deleteSharedEvent(any()); + verify(personalEventService, never()).restorePersonalEvents(any()); + verify(discussionParticipantService, never()).amIHost(any()); + verify(discussionRepository, never()).save(any()); + } + + @DisplayName("updateDiscussion: 시간이 변경된 경우 (host) - bitmap 삭제 및 개인 일정 복원 호출") + @Test + public void updateDiscussion_whenTimeChanged_callsBitmapDeletionAndRestore() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .dateStart(LocalDate.of(2025, 3, 1)) + .dateEnd(LocalDate.of(2025, 3, 1)) + .timeStart(LocalTime.of(10, 0)) + .timeEnd(LocalTime.of(12, 0)) + .duration(120) + .deadline(LocalDate.now().plusDays(10)) + .meetingMethod(MeetingMethod.ONLINE) + .location("Test Location") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + UpdateDiscussionRequest request = new UpdateDiscussionRequest( + "team meeting", + LocalDate.of(2025, 3, 1), + LocalDate.of(2025, 3, 2), + LocalTime.of(10, 0), + LocalTime.of(12, 0), + 120, + MeetingMethod.ONLINE, + "회의실 1", + LocalDate.now().plusDays(10) + ); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); + when(discussionRepository.save(discussion)).thenReturn(discussion); + + // when + DiscussionResponse response = discussionService.updateDiscussion(discussionId, request); + + // then + verify(discussionBitmapService).deleteDiscussionBitmaps(discussionId); + verify(personalEventService).restorePersonalEvents(discussion); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(discussionId); + assertThat(response.title()).isEqualTo("team meeting"); + } + + @DisplayName("updateDiscussion: 시간이 변경되지 않은 경우 (host) - bitmap 삭제 및 개인 일정 복원 미호출") + @Test + public void updateDiscussion_whenTimeNotChanged_doesNotCallBitmapDeletionAndRestore() { + // Given + Long discussionId = 2L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .dateStart(LocalDate.of(2025, 3, 1)) + .dateEnd(LocalDate.of(2025, 3, 1)) + .timeStart(LocalTime.of(10, 0)) + .timeEnd(LocalTime.of(12, 0)) + .duration(120) + .deadline(LocalDate.now().plusDays(10)) + .meetingMethod(MeetingMethod.ONLINE) + .location("Test Location") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + UpdateDiscussionRequest request = new UpdateDiscussionRequest( + "팀 회의", + LocalDate.of(2025, 3, 1), + LocalDate.of(2025, 3, 1), + LocalTime.of(10, 0), + LocalTime.of(12, 0), + 120, + MeetingMethod.ONLINE, + "회의실 1", + LocalDate.now().plusDays(10) + ); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); + when(discussionRepository.save(discussion)).thenReturn(discussion); + + // when + DiscussionResponse response = discussionService.updateDiscussion(discussionId, request); + + // then + verify(discussionBitmapService, never()).deleteDiscussionBitmaps(anyLong()); + verify(personalEventService, never()).restorePersonalEvents(any(Discussion.class)); + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(discussionId); + assertThat(response.title()).isEqualTo("팀 회의"); + } + + @DisplayName("updateDiscussion: host가 아닌 경우 예외 발생") + @Test + public void updateDiscussion_whenNotHost_throwsException() { + // Given + Long discussionId = 3L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(false); + + UpdateDiscussionRequest request = new UpdateDiscussionRequest( + "팀 회의", + LocalDate.of(2025, 3, 1), + LocalDate.of(2025, 3, 1), + LocalTime.of(10, 0), + LocalTime.of(12, 0), + 120, + MeetingMethod.ONLINE, + "회의실 1", + LocalDate.now().plusDays(10) + ); + + // when & then + assertThatThrownBy(() -> discussionService.updateDiscussion(discussionId, request)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ALLOWED_USER); + + verify(discussionRepository, never()).save(any(Discussion.class)); + verify(discussionBitmapService, never()).deleteDiscussionBitmaps(anyLong()); + verify(personalEventService, never()).restorePersonalEvents(any()); + } + + @DisplayName("논의 삭제 - 성공") + @Test + public void deleteDiscussion_whenValidConditions_deletesSuccessfully() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); + + // When + discussionService.deleteDiscussion(discussionId); + + // Then + verify(discussionBitmapService).deleteDiscussionBitmaps(discussionId); + verify(discussionParticipantService).deleteDiscussionParticipants(discussionId); + verify(discussionRepository).delete(discussion); + } + + @DisplayName("논의 삭제 - 호스트가 아닐 경우 예외 발생") + @Test + public void deleteDiscussion_whenNotHost_throwsException() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.ONGOING); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> discussionService.deleteDiscussion(discussionId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ALLOWED_USER); + + verify(discussionBitmapService, never()).deleteDiscussionBitmaps(anyLong()); + verify(discussionParticipantService, never()).deleteDiscussionParticipants(anyLong()); + verify(discussionRepository, never()).delete(any(Discussion.class)); } + @DisplayName("논의 삭제 - 논의 상태가 ONGOING이 아닐 경우 예외 발생") + @Test + public void deleteDiscussion_whenDiscussionNotOngoing_throwsException() { + // Given + Long discussionId = 1L; + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .build(); + discussion.setDiscussionStatus(DiscussionStatus.FINISHED); + ReflectionTestUtils.setField(discussion, "id", discussionId); + + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); + when(discussionParticipantService.amIHost(discussionId)).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> discussionService.deleteDiscussion(discussionId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DISCUSSION_NOT_ONGOING); + verify(discussionBitmapService, never()).deleteDiscussionBitmaps(anyLong()); + verify(discussionParticipantService, never()).deleteDiscussionParticipants(anyLong()); + verify(discussionRepository, never()).delete(any(Discussion.class)); + } } diff --git a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventControllerTest.java b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventControllerTest.java index 56904b48..dc8e01c7 100644 --- a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventControllerTest.java +++ b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import endolphin.backend.domain.personal_event.dto.PersonalEventResponse; +import endolphin.backend.domain.personal_event.entity.PersonalEvent; import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -47,11 +49,14 @@ void createPersonalEvent() throws Exception { "isAdjustable": false } """; +// PersonalEvent personalEvent = mock(PersonalEvent.class); +// given(personalEvent.getId()).willReturn(1L); PersonalEventResponse personalEventResponse = new PersonalEventResponse(1L, "title", LocalDateTime.of(2025, 2, 2, 10, 0), LocalDateTime.of(2025, 2, 2, 12, 0), false, "testCalendarId"); given(personalEventService.createWithRequest(any())).willReturn(personalEventResponse); + MvcResult result = mockMvc.perform(post("/api/v1/personal-event"). contentType(MediaType.APPLICATION_JSON) .content(requestJson)) diff --git a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessorTest.java b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessorTest.java index 28c5d777..62a9f573 100644 --- a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessorTest.java +++ b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventPreprocessorTest.java @@ -12,6 +12,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -226,12 +227,12 @@ public void test7() { private Discussion getDiscussion() { Discussion discussion = Mockito.mock(Discussion.class); - given(discussion.getId()).willReturn(1L); - given(discussion.getDiscussionStatus()).willReturn(DiscussionStatus.ONGOING); - given(discussion.getDateRangeStart()).willReturn(LocalDate.of(2024, 3, 10)); - given(discussion.getDateRangeEnd()).willReturn(LocalDate.of(2024, 3, 20)); - given(discussion.getTimeRangeStart()).willReturn(LocalTime.of(12, 0)); - given(discussion.getTimeRangeEnd()).willReturn(LocalTime.of(15, 0)); + lenient().when(discussion.getId()).thenReturn(1L); + lenient().when(discussion.getDiscussionStatus()).thenReturn(DiscussionStatus.ONGOING); + lenient().when(discussion.getDateRangeStart()).thenReturn(LocalDate.of(2024, 3, 10)); + lenient().when(discussion.getDateRangeEnd()).thenReturn(LocalDate.of(2024, 3, 20)); + lenient().when(discussion.getTimeRangeStart()).thenReturn(LocalTime.of(12, 0)); + lenient().when(discussion.getTimeRangeEnd()).thenReturn(LocalTime.of(15, 0)); return discussion; } @@ -251,4 +252,30 @@ private User getUser() { given(user.getId()).willReturn(1L); return user; } + + @Test + @DisplayName("사용자 파라미터 없이 전처리 테스트") + public void testPreprocessWithoutUserParameter() { + // given + Discussion discussion = getDiscussion(); + User user = getUser(); + + PersonalEvent personalEvent = Mockito.mock(PersonalEvent.class); + given(personalEvent.getStartTime()).willReturn(LocalDateTime.of(2024, 3, 15, 13, 0)); + given(personalEvent.getEndTime()).willReturn(LocalDateTime.of(2024, 3, 15, 14, 0)); + given(personalEvent.getId()).willReturn(100L); + given(personalEvent.getUser()).willReturn(user); + + Map offsetMap = Map.of(user, 2L); + given(discussionParticipantService.getDiscussionParticipantOffsets(discussion.getId())) + .willReturn(offsetMap); + + // when: preprocess 메서드 호출 + preprocessor.preprocess(List.of(personalEvent), discussion); + + // then: + then(discussionBitmapService).should(times(2)) + .setBitValue(anyLong(), any(Long.class), anyLong(), anyBoolean()); + } + } \ No newline at end of file diff --git a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventRepositoryTest.java b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventRepositoryTest.java index 898409b2..2648c152 100644 --- a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventRepositoryTest.java +++ b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventRepositoryTest.java @@ -2,11 +2,16 @@ import static org.assertj.core.api.Assertions.*; +import endolphin.backend.domain.discussion.entity.Discussion; +import endolphin.backend.domain.discussion.entity.DiscussionParticipant; +import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.personal_event.entity.PersonalEvent; import endolphin.backend.domain.user.UserRepository; import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.util.IdGenerator; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import org.hibernate.Hibernate; import org.junit.jupiter.api.BeforeEach; @@ -307,4 +312,103 @@ void findAllByUsersAndDateTimeRange_findsOverlappingEvents() { .extracting(PersonalEvent::getTitle) .containsExactlyInAnyOrder("Exact Match", "Overlap Start", "Overlap End"); } + + @Test + @DisplayName("Discussion과 날짜 범위로 PersonalEvent 조회 (fetch join 포함)") + void testFindPersonalEventsByDiscussionIdWithDateOverlap() { + // --- Setup: Discussion 및 DiscussionParticipant 생성 --- + Discussion discussion = Discussion.builder() + .title("Test Discussion") + .dateStart(LocalDate.now()) + .dateEnd(LocalDate.now().plusDays(1)) + .timeStart(LocalTime.of(9, 0)) + .timeEnd(LocalTime.of(17, 0)) + .duration(60) + .deadline(LocalDate.now().plusDays(2)) + .location("Test Location") + .status(DiscussionStatus.ONGOING) + .build(); + entityManager.persist(discussion); + + DiscussionParticipant dp = DiscussionParticipant.builder() + .discussion(discussion) + .user(testUser) + .isHost(true) + .userOffset(0L) + .build(); + entityManager.persist(dp); + + entityManager.flush(); + entityManager.clear(); + + LocalDateTime searchStart = LocalDateTime.of(2024, 2, 3, 0, 0); + LocalDateTime searchEnd = LocalDateTime.of(2024, 2, 3, 23, 59); + + PersonalEvent overlappingEvent = PersonalEvent.builder() + .user(testUser) + .title("Overlapping Event") + .startTime(LocalDateTime.of(2024, 2, 3, 10, 0)) + .endTime(LocalDateTime.of(2024, 2, 3, 12, 0)) + .build(); + PersonalEvent nonOverlappingEvent = PersonalEvent.builder() + .user(testUser) + .title("Non Overlapping Event") + .startTime(LocalDateTime.of(2024, 2, 4, 10, 0)) + .endTime(LocalDateTime.of(2024, 2, 4, 12, 0)) + .build(); + entityManager.persist(overlappingEvent); + entityManager.persist(nonOverlappingEvent); + entityManager.flush(); + entityManager.clear(); + + List results = personalEventRepository.findPersonalEventsByDiscussionIdWithDateOverlap( + discussion.getId(), searchStart, searchEnd); + + assertThat(results).hasSize(1); + PersonalEvent resultEvent = results.get(0); + assertThat(resultEvent.getTitle()).isEqualTo("Overlapping Event"); + assertThat(Hibernate.isInitialized(resultEvent.getUser())).isTrue(); + } + + @Test + @DisplayName("deletePersonalEventsByGoogleEventId 삭제 테스트: 해당 googleEventId의 이벤트만 삭제") + void testDeletePersonalEventsByGoogleEventId() { + // given: 동일한 googleEventId를 가진 이벤트 2건과 다른 googleEventId를 가진 이벤트 1건 저장 + personalEventRepository.deleteAll(); + entityManager.flush(); + entityManager.clear(); + + PersonalEvent event1 = PersonalEvent.builder() + .googleEventId(IdGenerator.generateSharedEventId(1L)) + .title("Event 1") + .startTime(LocalDateTime.now().plusDays(1)) + .endTime(LocalDateTime.now().plusDays(1).plusHours(1)) + .user(testUser) + .build(); + PersonalEvent event2 = PersonalEvent.builder() + .googleEventId(IdGenerator.generateSharedEventId(1L)) + .title("Event 2") + .startTime(LocalDateTime.now().plusDays(2)) + .endTime(LocalDateTime.now().plusDays(2).plusHours(1)) + .user(testUser) + .build(); + PersonalEvent event3 = PersonalEvent.builder() + .googleEventId(IdGenerator.generateSharedEventId(2L)) + .title("Event 3") + .startTime(LocalDateTime.now().plusDays(3)) + .endTime(LocalDateTime.now().plusDays(3).plusHours(1)) + .user(testUser) + .build(); + + personalEventRepository.save(event1); + personalEventRepository.save(event2); + personalEventRepository.save(event3); + + List events = personalEventRepository.findByGoogleEventId( + IdGenerator.generateSharedEventId(1L)); + + assertThat(events).hasSize(2); + assertThat(events.get(0).getGoogleEventId()).isEqualTo( + IdGenerator.generateSharedEventId(1L)); + } } \ No newline at end of file diff --git a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java index 766ee4d1..9b4eb286 100644 --- a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java @@ -5,10 +5,12 @@ import endolphin.backend.domain.personal_event.dto.PersonalEventRequest; import endolphin.backend.domain.personal_event.dto.PersonalEventResponse; import endolphin.backend.domain.personal_event.entity.PersonalEvent; +import endolphin.backend.domain.personal_event.event.DeletePersonalEvent; import endolphin.backend.domain.user.UserService; import endolphin.backend.domain.user.dto.UserInfoWithPersonalEvents; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.dto.ListResponse; +import endolphin.backend.global.util.IdGenerator; import java.time.LocalDate; import endolphin.backend.global.google.dto.GoogleEvent; import endolphin.backend.global.google.enums.GoogleEventStatus; @@ -231,14 +233,20 @@ public void testUpdateWithRequestByGoogleSync_Success() { PersonalEvent existingEvent = createWithRequest("new Title"); given(existingEvent.getStartTime()).willReturn(LocalDateTime.of(2024, 3, 10, 10, 0)); - given(existingEvent.getEndTime()).willReturn(LocalDateTime.of(2024, 3, 10, 12, 0)); PersonalEvent oldExistingEvent = createWithRequest("Old Title"); given(existingEvent.copy()).willReturn(oldExistingEvent); + PersonalEvent updatedPersonalEvent = createWithRequest("new Title"); + given(updatedPersonalEvent.getId()).willReturn(1L); + given(updatedPersonalEvent.getTitle()).willReturn("new Title"); + given(updatedPersonalEvent.getStartTime()).willReturn(LocalDateTime.of(2024, 3, 10, 10, 0)); + given(updatedPersonalEvent.getEndTime()).willReturn(LocalDateTime.of(2024, 3, 10, 12, 0)); + given(updatedPersonalEvent.getCalendarId()).willReturn("testCalendarId"); + given(updatedPersonalEvent.getIsAdjustable()).willReturn(false); + given(personalEventRepository.save(any())).willReturn(updatedPersonalEvent); + PersonalEvent existingEvent2 = createWithRequest("Old Title2"); - given(existingEvent2.getStartTime()).willReturn(LocalDateTime.of(2024, 5, 10, 7, 0)); - given(existingEvent2.getEndTime()).willReturn(LocalDateTime.of(2024, 5, 10, 12, 0)); given(personalEventRepository.findByGoogleEventIdAndCalendarId(eq(updatedGoogleEvent.eventId()), eq(googleCalendarId))) .willReturn(Optional.of(existingEvent)); @@ -463,4 +471,42 @@ void findUserInfoWithPersonalEventsByUsers_includesUsersWithNoEvents() { assertThat(user2Info.requirementOfAdjustment()).isFalse(); assertThat(user2Info.events()).isEmpty(); } + + @Test + @DisplayName("deletePersonalEventsByDiscussionId: 이벤트 목록 발행 후 삭제") + void testDeletePersonalEventsByDiscussionId() { + // Given + Long discussionId = 1L; + // IdGenerator를 통해 sharedEventId 생성 (실제 로직에 따라 결정적 값) + String sharedEventId = IdGenerator.generateSharedEventId(discussionId); + + // 테스트용 PersonalEvent 2건 생성 + PersonalEvent event1 = PersonalEvent.builder() + .title("Event 1") + .user(testUser) + .startTime(LocalDateTime.now().plusDays(1)) + .endTime(LocalDateTime.now().plusDays(1).plusHours(1)) + .build(); + PersonalEvent event2 = PersonalEvent.builder() + .title("Event 2") + .user(testUser) + .startTime(LocalDateTime.now().plusDays(2)) + .endTime(LocalDateTime.now().plusDays(2).plusHours(1)) + .build(); + List events = List.of(event1, event2); + + // personalEventRepository의 findByGoogleEventId(sharedEventId) 호출 시 위 리스트 반환하도록 모킹 + given(personalEventRepository.findByGoogleEventId(sharedEventId)).willReturn(events); + + // When + personalEventService.deletePersonalEventsByDiscussionId(discussionId); + + // Then + // 각 이벤트마다 DeletePersonalEvent 이벤트가 발행되었는지 검증 + verify(applicationEventPublisher, times(events.size())) + .publishEvent(any(DeletePersonalEvent.class)); + // 조회된 이벤트 리스트가 deleteAll로 삭제되었는지 검증 + verify(personalEventRepository, times(1)).deleteAll(events); + } + } diff --git a/backend/src/test/java/endolphin/backend/domain/shared_event/SharedEventServiceTest.java b/backend/src/test/java/endolphin/backend/domain/shared_event/SharedEventServiceTest.java index d5c72358..017d356c 100644 --- a/backend/src/test/java/endolphin/backend/domain/shared_event/SharedEventServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/shared_event/SharedEventServiceTest.java @@ -23,6 +23,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowableOfType; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; @@ -110,37 +112,44 @@ void getSharedEvent_NotFound_ThrowsException() { @DisplayName("공유 일정 삭제 성공") @Test void deleteSharedEvent_Success() { - Long sharedEventId = 100L; + Long discussionId = 100L; - when(sharedEventRepository.existsById(sharedEventId)).thenReturn(true); - doNothing().when(sharedEventRepository).deleteById(sharedEventId); + // Discussion 객체는 실제 생성자나 빌더가 없으면 mock으로 생성 가능 + Discussion discussion = mock(Discussion.class); + SharedEvent sharedEvent = SharedEvent.builder() + .discussion(discussion) + .start(LocalDateTime.now()) + .end(LocalDateTime.now().plusHours(1)) + .build(); + + // 실제 서비스에서는 discussionId로 조회하여 SharedEvent를 Optional로 반환받음 + when(sharedEventRepository.findByDiscussionId(discussionId)) + .thenReturn(Optional.of(sharedEvent)); - sharedEventService.deleteSharedEvent(sharedEventId); + sharedEventService.deleteSharedEvent(discussionId); - verify(sharedEventRepository, times(1)).existsById(sharedEventId); - verify(sharedEventRepository, times(1)).deleteById(sharedEventId); + // 공유 일정을 조회 및 삭제 메서드가 호출되었는지 검증 + verify(sharedEventRepository, times(1)).findByDiscussionId(discussionId); + verify(sharedEventRepository, times(1)).delete(sharedEvent); } @DisplayName("존재하지 않는 공유 일정 삭제 시 에러 응답 테스트") @Test void deleteSharedEvent_NotFound_ThrowsException() { - Long sharedEventId = 999L; + Long discussionId = 999L; - when(sharedEventRepository.existsById(sharedEventId)).thenReturn(false); + when(sharedEventRepository.findByDiscussionId(discussionId)) + .thenReturn(Optional.empty()); - ApiException exception = (ApiException) catchThrowable(() -> - sharedEventService.deleteSharedEvent(999L) + ApiException exception = assertThrows(ApiException.class, () -> + sharedEventService.deleteSharedEvent(discussionId) ); - ErrorResponse errorResponse = ErrorResponse.of(exception.getErrorCode()); - - assertThat(errorResponse).isNotNull(); - assertThat(errorResponse.message()).isEqualTo( - ErrorCode.SHARED_EVENT_NOT_FOUND.getMessage()); - assertThat(errorResponse.code()).isEqualTo(ErrorCode.SHARED_EVENT_NOT_FOUND.getCode()); + assertThat(exception).isNotNull(); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SHARED_EVENT_NOT_FOUND); - verify(sharedEventRepository, times(1)).existsById(sharedEventId); - verify(sharedEventRepository, never()).deleteById(anyLong()); + verify(sharedEventRepository, times(1)).findByDiscussionId(discussionId); + verify(sharedEventRepository, never()).delete(any(SharedEvent.class)); } @DisplayName("discussion Id 리스트에 해당하는 공유 일정 목록 조회 성공") diff --git a/backend/src/test/java/endolphin/backend/global/google/GoogleCalendarServiceTest.java b/backend/src/test/java/endolphin/backend/global/google/GoogleCalendarServiceTest.java index 50f6fd4a..370bcfd3 100644 --- a/backend/src/test/java/endolphin/backend/global/google/GoogleCalendarServiceTest.java +++ b/backend/src/test/java/endolphin/backend/global/google/GoogleCalendarServiceTest.java @@ -2,24 +2,20 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.*; import endolphin.backend.domain.calendar.CalendarService; import endolphin.backend.domain.calendar.entity.Calendar; -import endolphin.backend.domain.personal_event.PersonalEventService; import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.config.GoogleCalendarProperties; import endolphin.backend.global.config.GoogleCalendarUrl; import endolphin.backend.global.google.dto.GoogleCalendarDto; -import endolphin.backend.global.google.dto.GoogleEvent; + import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; + +import endolphin.backend.global.util.RetryExecutor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,7 +24,6 @@ import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class GoogleCalendarServiceTest { @@ -37,12 +32,14 @@ class GoogleCalendarServiceTest { private CalendarService calendarService; @Mock - private ApplicationEventPublisher eventPublisher; + private GoogleCalendarUrl googleCalendarUrl; @Mock - private PersonalEventService personalEventService; + private GoogleCalendarProperties googleCalendarProperties; + + @Mock + private RetryExecutor retryExecutor; - // 테스트 대상 클래스. 내부의 getPrimaryCalendar(), getCalendarEvents()등을 spy를 통해 부분 모킹합니다. @Spy @InjectMocks private GoogleCalendarService googleCalendarService; @@ -50,83 +47,43 @@ class GoogleCalendarServiceTest { @Test @DisplayName("사용자에게 캘린더가 이미 존재하고 구독 채널이 만료되지 않은 경우") void upsertGoogleCalendar_existingCalendar_validExpiration() { - // Given + // given User user = Mockito.mock(User.class); given(user.getId()).willReturn(1L); - // 캘린더가 존재한다고 응답 - given(calendarService.isExistingCalendar(user.getId())).willReturn(true); - - // 채널 만료시간이 현재보다 이후인 캘린더를 리턴하도록 stub 처리 Calendar calendar = Mockito.mock(Calendar.class); given(calendar.getChannelExpiration()).willReturn(LocalDateTime.now().plusDays(1)); - given(calendarService.getCalendarByUserId(eq(user.getId()))).willReturn(calendar); - - // When - googleCalendarService.upsertGoogleCalendar(user); - - // Then - then(calendarService).should().isExistingCalendar(user.getId()); - then(calendarService).should().getCalendarByUserId(user.getId()); - then(googleCalendarService).should(times(1)).subscribeToCalendar(any(), any()); - - // 신규 캘린더 생성, 이벤트 동기화, 캘린더 채널 구독은 발생하지 않아야 합니다. - then(calendarService).should(never()).createCalendar(any(), eq(user)); - then(personalEventService).should(never()).syncWithGoogleEvents(any(), eq(user), anyString()); - } - - @Test - @DisplayName("사용자에게 캘린더가 존재하지 않을 경우") - void upsertGoogleCalendar_noExistingCalendar() { - // Given - User user = Mockito.mock(User.class); - given(user.getId()).willReturn(1L); - - // 캘린더가 존재하지 않다고 응답 - given(calendarService.isExistingCalendar(user.getId())).willReturn(false); - - // 내부 메서드 getPrimaryCalendar() 를 stub 처리하여 더미 GoogleCalendarDto 리턴 - GoogleCalendarDto googleCalendarDto = new GoogleCalendarDto("primary", "title", "test"); + given(calendarService.getCalendarByUserId(user.getId())).willReturn(calendar); - doReturn(googleCalendarDto).when(googleCalendarService).getPrimaryCalendar(user); + // when + googleCalendarService.subscribeGoogleCalendar(user); - // 캘린더 생성시 stub 처리 - Calendar calendar = Mockito.mock(Calendar.class); - given(calendarService.createCalendar(googleCalendarDto, user)).willReturn(calendar); - - // (필요하다면 더미 이벤트를 추가) - doNothing().when(googleCalendarService).getCalendarEvents(googleCalendarDto.id(), user); - doNothing().when(googleCalendarService).subscribeToCalendar(calendar, user); - // When - googleCalendarService.upsertGoogleCalendar(user); - - // Then - then(calendarService).should().isExistingCalendar(user.getId()); - then(googleCalendarService).should().getPrimaryCalendar(user); - then(calendarService).should().createCalendar(googleCalendarDto, user); - then(googleCalendarService).should().getCalendarEvents(googleCalendarDto.id(), user); + // then + then(retryExecutor).should(never()).executeCalendarApiWithRetry(any(), any(), anyString()); } @Test @DisplayName("사용자에게 캘린더가 이미 존재하고 구독 채널이 만료된 경우") void upsertGoogleCalendar_existingCalendar_expired() { - // Given + // given User user = Mockito.mock(User.class); given(user.getId()).willReturn(1L); - given(calendarService.isExistingCalendar(user.getId())).willReturn(true); + Calendar calendar = Mockito.mock(Calendar.class); + given(calendar.getCalendarId()).willReturn("calendarId"); + given(calendar.getChannelExpiration()).willReturn(LocalDateTime.now().minusDays(1)); + given(calendarService.getCalendarByUserId(user.getId())).willReturn(calendar); - doNothing().when(googleCalendarService).subscribeToCalendar(calendar, user); - // When - googleCalendarService.upsertGoogleCalendar(user); + given(googleCalendarProperties.subscribeDuration()).willReturn(1); + given(googleCalendarUrl.getSubscribeUrl(anyString())).willReturn("http://subscribeUrl"); + given(googleCalendarUrl.webhookUrl()).willReturn("http://webhookUrl"); + + // when + googleCalendarService.subscribeGoogleCalendar(user); - // Then - then(calendarService).should().isExistingCalendar(user.getId()); - then(calendarService).should().getCalendarByUserId(user.getId()); - // 만료된 캘린더의 경우, 아무런 추가 작업도 하지 않아야 합니다. - then(calendarService).should(never()).createCalendar(any(), eq(user)); - then(personalEventService).should(never()).syncWithGoogleEvents(any(), eq(user), anyString()); + // then + then(retryExecutor).should(times(2)).executeCalendarApiWithRetry(any(), eq(user), eq("calendarId")); } } \ No newline at end of file diff --git a/backend/src/test/java/endolphin/backend/global/redis/DiscussionBitmapServiceTest.java b/backend/src/test/java/endolphin/backend/global/redis/DiscussionBitmapServiceTest.java index 26c1f62d..f4687c00 100644 --- a/backend/src/test/java/endolphin/backend/global/redis/DiscussionBitmapServiceTest.java +++ b/backend/src/test/java/endolphin/backend/global/redis/DiscussionBitmapServiceTest.java @@ -4,7 +4,6 @@ import endolphin.backend.global.util.TimeUtil; import java.time.LocalDateTime; -import java.time.ZoneOffset; import java.util.BitSet; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -51,7 +50,7 @@ public void testInitializeAndBitOperations() { @DisplayName("🗑️ 비트맵 비동기 삭제 테스트") @Test - public void testDeleteDiscussionBitmapsUsingScan() throws Exception { + public void testDeleteDiscussionBitmapsAsync() throws Exception { Long discussionId = 200L; Long dateTime = TimeUtil.convertToMinute(LocalDateTime.now()); @@ -64,14 +63,14 @@ public void testDeleteDiscussionBitmapsUsingScan() throws Exception { assertThat(beforeDelete).isNotNull(); // 데이터가 존재해야 함 // 3. 비동기 삭제 호출 및 완료 대기 - CompletableFuture deleteFuture = bitmapService.deleteDiscussionBitmapsUsingScan( + CompletableFuture deleteFuture = bitmapService.deleteDiscussionBitmapsAsync( discussionId); deleteFuture.get(5, TimeUnit.SECONDS); // 4. 삭제 후 데이터 검증 byte[] afterDelete = bitmapService.getBitmapData(discussionId, dateTime); assertThat(afterDelete) - .as("deleteDiscussionBitmapsUsingScan should remove the bitmap") + .as("deleteDiscussionBitmapsAsync should remove the bitmap") .isNull(); } @@ -133,4 +132,28 @@ public void testGetDataOfDiscussionId() { BitSet bs = BitSet.valueOf(data); assertThat(bs.get(5)).as("Offset 5의 비트는 true여야 합니다").isTrue(); } + + @Test + @DisplayName("참여자 비트 전체 삭제 테스트") + public void testSetUserBitsFalse() { + // given + Long discussionId = 100L; + long bitOffset = 3L; + long minuteKey = TimeUtil.convertToMinute(LocalDateTime.now()); + bitmapService.initializeBitmap(discussionId, minuteKey); + bitmapService.initializeBitmap(discussionId, minuteKey + 30); + + bitmapService.setBitValue(discussionId, minuteKey, bitOffset, true); + bitmapService.setBitValue(discussionId, minuteKey + 30, bitOffset, true); + + // when + bitmapService.deleteUsersFromDiscussion(discussionId, bitOffset); + + // then + boolean data = bitmapService.getBitValue(discussionId, minuteKey, bitOffset); + assertThat(data).isFalse(); + + data = bitmapService.getBitValue(discussionId, minuteKey + 30, bitOffset); + assertThat(data).isFalse(); + } } diff --git a/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java b/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java index 078b34f9..56315097 100644 --- a/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java +++ b/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java @@ -5,15 +5,19 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.global.error.exception.ApiException; +import endolphin.backend.global.security.PasswordEncoder; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.StringRedisTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class PasswordCountServiceTest { @@ -21,6 +25,9 @@ public class PasswordCountServiceTest { @Mock private StringRedisTemplate redisStringTemplate; + @Mock + private PasswordEncoder passwordEncoder; + @Mock private ValueOperations valueOperations; @@ -30,7 +37,7 @@ public class PasswordCountServiceTest { public void setUp() { // StringRedisTemplate의 opsForValue() 호출 시 mock ValueOperations 반환 when(redisStringTemplate.opsForValue()).thenReturn(valueOperations); - passwordCountService = new PasswordCountService(redisStringTemplate); + passwordCountService = new PasswordCountService(redisStringTemplate, passwordEncoder); } @Test @@ -38,29 +45,36 @@ public void testIncreaseCount_Success_FirstFailure() { Long userId = 1L; Long discussionId = 1L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + ReflectionTestUtils.setField(discussion, "password", "password2"); // 이전에 값이 없으면 null 반환 (즉, 첫 실패) when(valueOperations.get(redisKey)).thenReturn(null); // 증가 후 값으로 1 반환 when(valueOperations.increment(redisKey)).thenReturn(1L); - passwordCountService.increaseCount(userId, discussionId); + passwordCountService.tryEnter(userId, discussion, "password"); // 첫 실패시 expire가 설정되어야 함 (5분 = 5*60*1000 밀리초) verify(redisStringTemplate).expire(eq(redisKey), eq(5 * 60 * 1000L), eq(TimeUnit.MILLISECONDS)); } + @DisplayName("첫 실패 후 성공") @Test public void testIncreaseCount_Success_SubsequentFailure() { Long userId = 2L; Long discussionId = 10L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + ReflectionTestUtils.setField(discussion, "password", "password"); when(valueOperations.get(redisKey)).thenReturn("2"); - when(valueOperations.increment(redisKey)).thenReturn(3L); + when(passwordEncoder.matches(discussionId, discussion.getPassword(), "password")).thenReturn(true); - passwordCountService.increaseCount(userId, discussionId); + passwordCountService.tryEnter(userId, discussion, "password"); } @Test @@ -69,10 +83,13 @@ public void testIncreaseCount_ExceedsMaxAttempts() { Long discussionId = 20L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + when(valueOperations.get(redisKey)).thenReturn("5"); ApiException exception = assertThrows(ApiException.class, () -> - passwordCountService.increaseCount(userId, discussionId) + passwordCountService.tryEnter(userId, discussion, "password") ); } diff --git a/backend/src/test/java/endolphin/backend/global/scheduler/DiscussionStatusSchedulerTest.java b/backend/src/test/java/endolphin/backend/global/scheduler/DiscussionStatusSchedulerTest.java index 6dc5e65b..68a0b636 100644 --- a/backend/src/test/java/endolphin/backend/global/scheduler/DiscussionStatusSchedulerTest.java +++ b/backend/src/test/java/endolphin/backend/global/scheduler/DiscussionStatusSchedulerTest.java @@ -59,7 +59,7 @@ void updateStatus_ongoingDiscussion_dateRangeEndBeforeToday_updatesStatusToFinis discussion.setDiscussionStatus(DiscussionStatus.ONGOING); ReflectionTestUtils.setField(discussion, "id", 1L); - when(discussionBitmapService.deleteDiscussionBitmapsUsingScan(any(Long.class))) + when(discussionBitmapService.deleteDiscussionBitmapsAsync(any(Long.class))) .thenReturn(CompletableFuture.completedFuture(null)); @@ -151,7 +151,7 @@ void updateDiscussionStatusAtMidnight_handlesExceptionAndContinues() { discussion2.setDiscussionStatus(DiscussionStatus.ONGOING); ReflectionTestUtils.setField(discussion2, "id", 2L); - when(discussionBitmapService.deleteDiscussionBitmapsUsingScan(any(Long.class))) + when(discussionBitmapService.deleteDiscussionBitmapsAsync(any(Long.class))) .thenReturn(CompletableFuture.completedFuture(null)); when(discussionRepository.findByDiscussionStatusNot(DiscussionStatus.FINISHED)).thenReturn(List.of(discussion1, discussion2)); diff --git a/backend/src/test/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpSchedulerTest.java b/backend/src/test/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpSchedulerTest.java new file mode 100644 index 00000000..2957eb9d --- /dev/null +++ b/backend/src/test/java/endolphin/backend/global/scheduler/RefreshTokenCleanUpSchedulerTest.java @@ -0,0 +1,65 @@ +package endolphin.backend.global.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +import endolphin.backend.domain.auth.RefreshTokenRepository; +import endolphin.backend.domain.auth.entity.RefreshToken; +import endolphin.backend.domain.user.UserRepository; +import endolphin.backend.domain.user.entity.User; +import endolphin.backend.global.util.TimeUtil; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; + +@SpringBootTest +public class RefreshTokenCleanUpSchedulerTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenCleanUpScheduler scheduler; + + @BeforeEach + void setUp() { + refreshTokenRepository.deleteAll(); + User user = User.builder() + .email("email") + .name("name") + .picture("picture") + .build(); + userRepository.save(user); + + RefreshToken expiredToken = RefreshToken.builder() + .user(user) + .token("expired-token") + .expiration(TimeUtil.getNow().minusHours(1)) + .build(); + refreshTokenRepository.save(expiredToken); + + RefreshToken validToken = RefreshToken.builder() + .user(user) + .token("valid-token") + .expiration(TimeUtil.getNow().plusHours(1)) + .build(); + refreshTokenRepository.save(validToken); + } + + @Test + void testCleanupExpiredRefreshTokens() { + scheduler.cleanupExpiredRefreshTokens(); + + List remainingTokens = refreshTokenRepository.findAll(); + + assertThat(remainingTokens) + .hasSize(1) + .extracting(RefreshToken::getToken) + .containsExactly("valid-token"); + } +} diff --git a/backend/src/test/java/endolphin/backend/global/security/JwtAuthFilterTest.java b/backend/src/test/java/endolphin/backend/global/security/JwtAuthFilterTest.java index c7dccce0..1e7f9f26 100644 --- a/backend/src/test/java/endolphin/backend/global/security/JwtAuthFilterTest.java +++ b/backend/src/test/java/endolphin/backend/global/security/JwtAuthFilterTest.java @@ -29,7 +29,7 @@ void setUp() { @Test void validTokenShouldSetUserContextInsideChain() throws Exception { // given - String token = jwtProvider.createToken(123L, "test@domain.com"); + String token = jwtProvider.createToken(123L, "test@domain.com").token(); MockHttpServletRequest req = new MockHttpServletRequest(); req.addHeader("Authorization", "Bearer " + token); MockHttpServletResponse res = new MockHttpServletResponse(); diff --git a/backend/src/test/java/endolphin/backend/global/security/JwtProviderTest.java b/backend/src/test/java/endolphin/backend/global/security/JwtProviderTest.java index 47e51c47..0ee92083 100644 --- a/backend/src/test/java/endolphin/backend/global/security/JwtProviderTest.java +++ b/backend/src/test/java/endolphin/backend/global/security/JwtProviderTest.java @@ -28,7 +28,7 @@ void createAndValidateToken() { String email = "test@example.com"; // when - String token = jwtProvider.createToken(userId, email); + String token = jwtProvider.createToken(userId, email).token(); // then assertThat(token).isNotNull(); diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 304b6dc5..7f4c79be 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -54,6 +54,14 @@ google: jwt: secret: "my-very-long-and-secure-secret-key-which-is-at-least-32-chars-test" expired: 1000 + refresh: + expired: 1000 + cookie: + name: "test" + secure: false + path: "/refresh" + max-age: 0 + http-only: true springdoc: packages-to-scan: endolphin.backend @@ -74,7 +82,9 @@ springdoc: app: frontend: url: "test" - callback: "test" + callback: + login: "test" + google-calendar: "test" server: domain: "test" diff --git a/frontend/.changeset/README.md b/frontend/.changeset/README.md new file mode 100644 index 00000000..e5b6d8d6 --- /dev/null +++ b/frontend/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/frontend/.changeset/config.json b/frontend/.changeset/config.json new file mode 100644 index 00000000..1137bf0c --- /dev/null +++ b/frontend/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": [ + "@changesets/changelog-git", + { "repo": "softeer5th/Team4-enDolphin" } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@endolphin/client", "@endolphin/server"] +} diff --git a/frontend/.changeset/cyan-colts-rule.md b/frontend/.changeset/cyan-colts-rule.md new file mode 100644 index 00000000..6d98491a --- /dev/null +++ b/frontend/.changeset/cyan-colts-rule.md @@ -0,0 +1,9 @@ +--- +"@endolphin/date-time": major +"@endolphin/calendar": major +"@endolphin/theme": major +"@endolphin/core": major +"@endolphin/ui": major +--- + +🚀 diff --git a/frontend/.gitignore b/frontend/.gitignore index 2b81ce0b..f0abd802 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,4 +25,10 @@ dist-ssr *.sw? *storybook.log -routeTree.gen.ts \ No newline at end of file +routeTree.gen.ts + +# localhost를 https 환경에서 동작시키기 위한 mkcert 인증서 key files +mkcert/* + +# build caches +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json deleted file mode 100644 index d2cedc4a..00000000 --- a/frontend/.vscode/settings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", - "source.fixAll.stylelint": "explicit" - }, - "editor.formatOnSave": false, - "typescript.preferences.jsxAttributeCompletionStyle": "none", - "javascript.preferences.jsxAttributeCompletionStyle": "none", - "stylelint.enable": true, - "stylelint.validate": [ - "css", - "scss", - "postcss", - "typescript", - "typescriptreact" - ], - "stylelint.customSyntax": "postcss-styled-syntax", - "css.validate": false, - "less.validate": false, - "scss.validate": false, - "typescript.tsdk": "node_modules/typescript/lib" -} diff --git a/frontend/apps/client/.gitignore b/frontend/apps/client/.gitignore new file mode 100644 index 00000000..7002e221 --- /dev/null +++ b/frontend/apps/client/.gitignore @@ -0,0 +1,2 @@ +# localhost를 https 환경에서 동작시키기 위한 mkcert 인증서 key files +mkcert/* diff --git a/frontend/.storybook/main.ts b/frontend/apps/client/.storybook/main.ts similarity index 100% rename from frontend/.storybook/main.ts rename to frontend/apps/client/.storybook/main.ts diff --git a/frontend/.storybook/preview.ts b/frontend/apps/client/.storybook/preview.ts similarity index 100% rename from frontend/.storybook/preview.ts rename to frontend/apps/client/.storybook/preview.ts diff --git a/frontend/apps/client/.vite/deps/_metadata.json b/frontend/apps/client/.vite/deps/_metadata.json new file mode 100644 index 00000000..9721cba0 --- /dev/null +++ b/frontend/apps/client/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "ba742492", + "configHash": "424a9e19", + "lockfileHash": "e3b0c442", + "browserHash": "18ebc066", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/apps/client/.vite/deps/package.json b/frontend/apps/client/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/frontend/apps/client/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/apps/client/__tests__/mocks/events.ts b/frontend/apps/client/__tests__/mocks/events.ts new file mode 100644 index 00000000..99868f2c --- /dev/null +++ b/frontend/apps/client/__tests__/mocks/events.ts @@ -0,0 +1,19 @@ +import type { PersonalEventResponse } from '@/features/my-calendar/model'; + +export const createCards = (num: number): PersonalEventResponse[] => + Array.from({ length: num }, (_, i) => { + const id = i + 1; + const baseDate = new Date('2023-10-01T00:00:00'); + const start = new Date(baseDate.getTime() + i * 30 * 60 * 1000); + const end = new Date(start.getTime() + 60 * 60 * 1000); + + return { + id, + title: `Test Event ${id}`, + startDateTime: start.toISOString(), + endDateTime: end.toISOString(), + isAdjustable: id % 2 === 1, + googleEventId: `google-event-id-${id}`, + calendarId: `calendar-id-${id}`, + }; + }); diff --git a/frontend/apps/client/__tests__/unit/my-calendar/card.tsx b/frontend/apps/client/__tests__/unit/my-calendar/card.tsx new file mode 100644 index 00000000..850c3d9c --- /dev/null +++ b/frontend/apps/client/__tests__/unit/my-calendar/card.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react'; + +import { CalendarCardList } from '@/features/my-calendar/ui/CalendarCardList'; + +import { createCards } from '../../mocks/events'; + +describe('CalendarCardList', () => { + it('일반 일정 카드 리스트 컨테이너 렌더링', () => { + // given + const cards = createCards(10); + + // when + const { container } = render(); + const cardList = container.querySelector('[class*="CalendarCardList"]'); + + // then + expect(cardList).toBeVisible(); + }); + it('일반 일정 카드 아이템 렌더링', () => { + // given + const cards = createCards(10); + + // when + const { container } = render(); + + // then + const cardList = container.querySelector('[class*="CalendarCardList"]'); + expect(cardList?.childElementCount).toBe(cards.length); + }); + it.skip('겹치는 날짜 정렬 알고리즘 카드 렌더링 시간 100ms 이하', () => { + // given + const cards = createCards(1000); + + // when + const start = performance.now(); + render(); + const end = performance.now(); + + // then + expect(end - start).toBeLessThan(100); + }); +}, +); \ No newline at end of file diff --git a/frontend/index.html b/frontend/apps/client/index.html similarity index 91% rename from frontend/index.html rename to frontend/apps/client/index.html index eade7d67..117f84c2 100644 --- a/frontend/index.html +++ b/frontend/apps/client/index.html @@ -4,7 +4,6 @@ - 언제만나
diff --git a/frontend/apps/client/package.json b/frontend/apps/client/package.json new file mode 100644 index 00000000..1c8012bf --- /dev/null +++ b/frontend/apps/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "@endolphin/client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "pnpm --filter server start", + "build": "vite build && tsc -b", + "test": "vitest", + "lint": "eslint .", + "coverage": "vitest run --coverage", + "optimize-image": "node src/scripts/optimize-image.cjs", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "generate-cert": "bash src/scripts/generate-cert.sh" + }, + "dependencies": { + "@endolphin/calendar": "workspace:*", + "@endolphin/core": "workspace:*", + "@endolphin/date-time": "workspace:*", + "@endolphin/theme": "workspace:*", + "@endolphin/ui": "workspace:*", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-router": "^1.109.2", + "jotai": "^2.12.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@endolphin/vitest-config": "workspace:*" + } +} diff --git a/frontend/public/fonts/Pretendard-Light.woff2 b/frontend/apps/client/public/fonts/Pretendard-Light.woff2 similarity index 100% rename from frontend/public/fonts/Pretendard-Light.woff2 rename to frontend/apps/client/public/fonts/Pretendard-Light.woff2 diff --git a/frontend/public/fonts/Pretendard-Medium.woff2 b/frontend/apps/client/public/fonts/Pretendard-Medium.woff2 similarity index 100% rename from frontend/public/fonts/Pretendard-Medium.woff2 rename to frontend/apps/client/public/fonts/Pretendard-Medium.woff2 diff --git a/frontend/public/fonts/Pretendard-Regular.woff2 b/frontend/apps/client/public/fonts/Pretendard-Regular.woff2 similarity index 100% rename from frontend/public/fonts/Pretendard-Regular.woff2 rename to frontend/apps/client/public/fonts/Pretendard-Regular.woff2 diff --git a/frontend/public/fonts/Pretendard-SemiBold.woff2 b/frontend/apps/client/public/fonts/Pretendard-SemiBold.woff2 similarity index 100% rename from frontend/public/fonts/Pretendard-SemiBold.woff2 rename to frontend/apps/client/public/fonts/Pretendard-SemiBold.woff2 diff --git a/frontend/public/images/assets/calendar.webp b/frontend/apps/client/public/images/assets/calendar.webp similarity index 100% rename from frontend/public/images/assets/calendar.webp rename to frontend/apps/client/public/images/assets/calendar.webp diff --git a/frontend/public/images/assets/error.webp b/frontend/apps/client/public/images/assets/error.webp similarity index 100% rename from frontend/public/images/assets/error.webp rename to frontend/apps/client/public/images/assets/error.webp diff --git a/frontend/apps/client/public/images/assets/google-login-icon.webp b/frontend/apps/client/public/images/assets/google-login-icon.webp new file mode 100644 index 00000000..464719ca Binary files /dev/null and b/frontend/apps/client/public/images/assets/google-login-icon.webp differ diff --git a/frontend/public/images/landing/landing-1-1280w.webp b/frontend/apps/client/public/images/landing/landing-1-1280w.webp similarity index 100% rename from frontend/public/images/landing/landing-1-1280w.webp rename to frontend/apps/client/public/images/landing/landing-1-1280w.webp diff --git a/frontend/public/images/landing/landing-1-480w.webp b/frontend/apps/client/public/images/landing/landing-1-480w.webp similarity index 100% rename from frontend/public/images/landing/landing-1-480w.webp rename to frontend/apps/client/public/images/landing/landing-1-480w.webp diff --git a/frontend/public/images/landing/landing-1-768w.webp b/frontend/apps/client/public/images/landing/landing-1-768w.webp similarity index 100% rename from frontend/public/images/landing/landing-1-768w.webp rename to frontend/apps/client/public/images/landing/landing-1-768w.webp diff --git a/frontend/public/images/landing/landing-1.webp b/frontend/apps/client/public/images/landing/landing-1.webp similarity index 100% rename from frontend/public/images/landing/landing-1.webp rename to frontend/apps/client/public/images/landing/landing-1.webp diff --git a/frontend/public/images/landing/landing-2-1280w.webp b/frontend/apps/client/public/images/landing/landing-2-1280w.webp similarity index 100% rename from frontend/public/images/landing/landing-2-1280w.webp rename to frontend/apps/client/public/images/landing/landing-2-1280w.webp diff --git a/frontend/public/images/landing/landing-2-480w.webp b/frontend/apps/client/public/images/landing/landing-2-480w.webp similarity index 100% rename from frontend/public/images/landing/landing-2-480w.webp rename to frontend/apps/client/public/images/landing/landing-2-480w.webp diff --git a/frontend/public/images/landing/landing-2-768w.webp b/frontend/apps/client/public/images/landing/landing-2-768w.webp similarity index 100% rename from frontend/public/images/landing/landing-2-768w.webp rename to frontend/apps/client/public/images/landing/landing-2-768w.webp diff --git a/frontend/public/images/landing/landing-2.webp b/frontend/apps/client/public/images/landing/landing-2.webp similarity index 100% rename from frontend/public/images/landing/landing-2.webp rename to frontend/apps/client/public/images/landing/landing-2.webp diff --git a/frontend/public/logo.svg b/frontend/apps/client/public/logo.svg similarity index 100% rename from frontend/public/logo.svg rename to frontend/apps/client/public/logo.svg diff --git a/frontend/apps/client/setup-file.ts b/frontend/apps/client/setup-file.ts new file mode 100644 index 00000000..331666ce --- /dev/null +++ b/frontend/apps/client/setup-file.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; \ No newline at end of file diff --git a/frontend/src/components/Modal/GlobalModal.tsx b/frontend/apps/client/src/components/Modal/GlobalModal.tsx similarity index 92% rename from frontend/src/components/Modal/GlobalModal.tsx rename to frontend/apps/client/src/components/Modal/GlobalModal.tsx index 5cfe9a75..6221371b 100644 --- a/frontend/src/components/Modal/GlobalModal.tsx +++ b/frontend/apps/client/src/components/Modal/GlobalModal.tsx @@ -1,7 +1,7 @@ +import { useClickOutside } from '@endolphin/core/hooks'; import { useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { useClickOutside } from '@/hooks/useClickOutside'; import { useGlobalModal } from '@/store/global/modal'; import { Modal } from '.'; diff --git a/frontend/src/components/Modal/Modal.stories.tsx b/frontend/apps/client/src/components/Modal/Modal.stories.tsx similarity index 96% rename from frontend/src/components/Modal/Modal.stories.tsx rename to frontend/apps/client/src/components/Modal/Modal.stories.tsx index 00527b1f..57a3bce0 100644 --- a/frontend/src/components/Modal/Modal.stories.tsx +++ b/frontend/apps/client/src/components/Modal/Modal.stories.tsx @@ -1,7 +1,6 @@ +import { Button, Chip } from '@endolphin/ui'; import type { Meta } from '@storybook/react'; -import Button from '../Button'; -import { Chip } from '../Chip'; import type { ModalProps } from '.'; import { Modal } from '.'; diff --git a/frontend/src/components/Modal/ModalContents.tsx b/frontend/apps/client/src/components/Modal/ModalContents.tsx similarity index 68% rename from frontend/src/components/Modal/ModalContents.tsx rename to frontend/apps/client/src/components/Modal/ModalContents.tsx index 322cc989..709528ad 100644 --- a/frontend/src/components/Modal/ModalContents.tsx +++ b/frontend/apps/client/src/components/Modal/ModalContents.tsx @@ -1,7 +1,6 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - import { descriptionStyle } from './index.css'; interface ModalContentsProps extends PropsWithChildren { @@ -9,7 +8,5 @@ interface ModalContentsProps extends PropsWithChildren { } export const ModalContents = ({ className, children }: ModalContentsProps) => ( -
- {children} -
-); \ No newline at end of file +
{children}
+); diff --git a/frontend/src/components/Modal/ModalFooter.tsx b/frontend/apps/client/src/components/Modal/ModalFooter.tsx similarity index 90% rename from frontend/src/components/Modal/ModalFooter.tsx rename to frontend/apps/client/src/components/Modal/ModalFooter.tsx index ffe66a94..e3cffb1c 100644 --- a/frontend/src/components/Modal/ModalFooter.tsx +++ b/frontend/apps/client/src/components/Modal/ModalFooter.tsx @@ -1,7 +1,6 @@ +import { Flex } from '@endolphin/ui'; import type { PropsWithChildren } from 'react'; -import { Flex } from '../Flex'; - interface ModalFooterProps extends PropsWithChildren { className?: string; } diff --git a/frontend/src/components/Modal/index.css.ts b/frontend/apps/client/src/components/Modal/index.css.ts similarity index 95% rename from frontend/src/components/Modal/index.css.ts rename to frontend/apps/client/src/components/Modal/index.css.ts index 5939cb06..4c7bc1e1 100644 --- a/frontend/src/components/Modal/index.css.ts +++ b/frontend/apps/client/src/components/Modal/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: '34rem', height: '22.8125rem', diff --git a/frontend/src/components/Modal/index.tsx b/frontend/apps/client/src/components/Modal/index.tsx similarity index 88% rename from frontend/src/components/Modal/index.tsx rename to frontend/apps/client/src/components/Modal/index.tsx index 298e8c08..aac1356e 100644 --- a/frontend/src/components/Modal/index.tsx +++ b/frontend/apps/client/src/components/Modal/index.tsx @@ -1,10 +1,9 @@ -import clsx from '@utils/clsx'; +import { clsx } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Text } from '@endolphin/ui'; import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; import { forwardRef } from 'react'; -import { vars } from '@/theme/index.css'; - -import { Text } from '../Text'; import { containerStyle, descriptionStyle, titleStyle } from './index.css'; import { ModalContents } from './ModalContents'; import { ModalFooter } from './ModalFooter'; @@ -13,9 +12,9 @@ type ModalType = 'default' | 'error'; export interface ModalProps { type?: ModalType; - subTitle: string; - title: string; - description?: string; + subTitle: ReactNode; + title: ReactNode; + description?: ReactNode; isOpen: boolean; className?: string; children?: ReactNode; diff --git a/frontend/src/components/Notification/GlobalNotification.tsx b/frontend/apps/client/src/components/Notification/GlobalNotification.tsx similarity index 83% rename from frontend/src/components/Notification/GlobalNotification.tsx rename to frontend/apps/client/src/components/Notification/GlobalNotification.tsx index 9793d726..ab48fd31 100644 --- a/frontend/src/components/Notification/GlobalNotification.tsx +++ b/frontend/apps/client/src/components/Notification/GlobalNotification.tsx @@ -1,15 +1,15 @@ +import { animation } from '@endolphin/theme'; import { useAtom } from 'jotai'; import { createPortal } from 'react-dom'; import { type NotiAtom, notiAtomsAtom } from '@/store/global/notification'; -import { fadeInAndOutStyle } from '@/theme/animation.css'; import { Notification } from '.'; import { notificationsStyle } from './index.css'; const Noti = ({ noti }: { noti: NotiAtom }) => { const [notification] = useAtom(noti); - return ; + return ; }; export const GlobalNotifications = () => { diff --git a/frontend/src/components/Notification/Notification.stories.tsx b/frontend/apps/client/src/components/Notification/Notification.stories.tsx similarity index 100% rename from frontend/src/components/Notification/Notification.stories.tsx rename to frontend/apps/client/src/components/Notification/Notification.stories.tsx diff --git a/frontend/src/components/Notification/index.css.ts b/frontend/apps/client/src/components/Notification/index.css.ts similarity index 96% rename from frontend/src/components/Notification/index.css.ts rename to frontend/apps/client/src/components/Notification/index.css.ts index 42d38086..006773be 100644 --- a/frontend/src/components/Notification/index.css.ts +++ b/frontend/apps/client/src/components/Notification/index.css.ts @@ -1,9 +1,8 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = recipe({ base: { width: 'fit-content', diff --git a/frontend/src/components/Notification/index.tsx b/frontend/apps/client/src/components/Notification/index.tsx similarity index 55% rename from frontend/src/components/Notification/index.tsx rename to frontend/apps/client/src/components/Notification/index.tsx index 4a92c27d..fb3dc9e4 100644 --- a/frontend/src/components/Notification/index.tsx +++ b/frontend/apps/client/src/components/Notification/index.tsx @@ -1,10 +1,8 @@ +import { clsx } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Icon, Text } from '@endolphin/ui'; import type { ReactNode } from 'react'; -import { vars } from '@/theme/index.css'; - -import clsx from '../../utils/clsx'; -import { CircleCheck, TriangleWarning } from '../Icon'; -import { Text } from '../Text'; import { containerStyle, contentsStyle } from './index.css'; type NotiType = 'success' | 'error'; @@ -16,32 +14,25 @@ export interface NotificationProps { className?: string; } -export const Notification = ({ - type, - title, - description, - className, -}: NotificationProps) => { +export const Notification = ({ type, title, description, className }: NotificationProps) => { const typeIconMap: Record = { - success: , - error: , + success: , + error: , }; return ( -
+
{typeIconMap[type]}
{title} - {description && + {description && ( {description} - } + + )}
); -}; \ No newline at end of file +}; diff --git a/frontend/apps/client/src/constants/date.ts b/frontend/apps/client/src/constants/date.ts new file mode 100644 index 00000000..1fb538df --- /dev/null +++ b/frontend/apps/client/src/constants/date.ts @@ -0,0 +1,32 @@ +export type Time = number | 'all' | 'empty'; + +export type WEEKDAY = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT'; + +export const TIMES: readonly number[] = Object.freeze(new Array(24).fill(0) + .map((_, i) => i)); + +export const WEEK: readonly WEEKDAY[] = Object.freeze([ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]); + +export const WEEK_MAP: Record = Object.freeze({ + 1: '첫째주', + 2: '둘째주', + 3: '셋째주', + 4: '넷째주', + 5: '다섯째주', +}); + +export const MINUTES = Object.freeze(new Array(4).fill(0) + .map((_, i) => i * 15)); + +export const MINUTES_HALF = (totalTime: number, startTime: number) => + Object.freeze(new Array(totalTime * 2).fill(0) + .map((_, i) => startTime + i * 30)); +export const TIME_HEIGHT = 66; diff --git a/frontend/src/constants/error.ts b/frontend/apps/client/src/constants/error.ts similarity index 80% rename from frontend/src/constants/error.ts rename to frontend/apps/client/src/constants/error.ts index 50f2fd6c..4b618f76 100644 --- a/frontend/src/constants/error.ts +++ b/frontend/apps/client/src/constants/error.ts @@ -8,6 +8,7 @@ export const errorMessages = { D003: '진행 중인 일정 조율이 아니에요', D004: '비밀번호 입력 횟수가 초과되었어요', D005: '비밀번호가 설정된 일정 조율이에요', + D006: '일정 조율이 아직 진행중이에요', P001: '개인 일정을 찾을 수 없어요', P002: '해당 일정을 수정할 수 없어요', S001: '공유 일정을 찾을 수 없어요', @@ -24,10 +25,16 @@ export const errorMessages = { O003: 'OAuth 권한이 없어요', O004: '잘못된 OAuth 코드예요', O005: '잘못된 OAuth 사용자 정보예요', + O006: 'refresh 토큰이 만료되었어요', GC001: '동기화 토큰이 만료되었어요', + RT001: 'refresh 토큰이 없어요', + RT002: 'refresh 토큰이 유효하지 않아요', + RT003: 'refresh 토큰이 만료되었어요', } as const; export const DEFAULT_ERROR_MESSAGE = '알 수 없는 오류가 발생했습니다.'; +export const NETWORK_ERROR_MESSAGE = '언제만나 서버가 응답하지 않습니다. 잠시 후 다시 시도해주세요.'; + export type ErrorMessages = typeof errorMessages; export type ErrorCode = keyof ErrorMessages; \ No newline at end of file diff --git a/frontend/src/constants/regex.ts b/frontend/apps/client/src/constants/regex.ts similarity index 65% rename from frontend/src/constants/regex.ts rename to frontend/apps/client/src/constants/regex.ts index 48e433b1..d73bd224 100644 --- a/frontend/src/constants/regex.ts +++ b/frontend/apps/client/src/constants/regex.ts @@ -1,3 +1,4 @@ export const DATE_BAR = /^\d{4}-\d{2}-\d{2}$/; +export const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; export const TIME = /^\d{2}:\d{2}$/; export const PASSWORD = /^[0-9]{4,6}$/; \ No newline at end of file diff --git a/frontend/src/constants/statusMap.ts b/frontend/apps/client/src/constants/statusMap.ts similarity index 100% rename from frontend/src/constants/statusMap.ts rename to frontend/apps/client/src/constants/statusMap.ts diff --git a/frontend/src/envconfig.ts b/frontend/apps/client/src/envconfig.ts similarity index 75% rename from frontend/src/envconfig.ts rename to frontend/apps/client/src/envconfig.ts index 142e76ed..40cf0d56 100644 --- a/frontend/src/envconfig.ts +++ b/frontend/apps/client/src/envconfig.ts @@ -6,4 +6,5 @@ export const serviceENV = Object.freeze({ BASE_URL: import.meta.env.VITE_API_URL, CLIENT_URL: import.meta.env.VITE_CLIENT_URL, GOOGLE_OAUTH_URL: import.meta.env.VITE_GOOGLE_OAUTH_URL, + GOOGLE_CALENDAR_PERMISSION_URL: import.meta.env.VITE_GOOGLE_CALENDAR_PERMISSION_URL, }); \ No newline at end of file diff --git a/frontend/src/features/discussion/api/index.ts b/frontend/apps/client/src/features/discussion/api/index.ts similarity index 84% rename from frontend/src/features/discussion/api/index.ts rename to frontend/apps/client/src/features/discussion/api/index.ts index 79d98a5f..5039f164 100644 --- a/frontend/src/features/discussion/api/index.ts +++ b/frontend/apps/client/src/features/discussion/api/index.ts @@ -1,15 +1,16 @@ -import { request } from '@/utils/fetch'; +import { request } from '@utils/fetch'; import type { DiscussionCalendarRequest, DiscussionCalendarResponse, - DiscussionConfirmRequest, - DiscussionConfirmResponse, + DiscussionConfirmRequest, DiscussionParticipantResponse, DiscussionRankRequest, DiscussionRankResponse, DiscussionRequest, - DiscussionResponse, + DiscussionResponse } from '../model'; +import { + DiscussionConfirmResponse, } from '../model'; export const discussionApi = { @@ -27,6 +28,11 @@ export const discussionApi = { const response = await request.get(`/api/v1/discussion/${id}/role`); return response; }, + + postRestart: async (id: string): Promise => { + const response = await request.post(`/api/v1/discussion/${id}/restart`); + return response; + }, }; export const candidateApi = { @@ -58,11 +64,11 @@ export const candidateApi = { { id, body }: { id: string; body: DiscussionConfirmRequest }, ): Promise => { const response = await request.post(`/api/v1/discussion/${id}/confirm`, { body }); - return response; + return DiscussionConfirmResponse.parse(response); }, getDiscussionConfirm: async (id: string): Promise => { const response = await request.get(`/api/v1/discussion/${id}/shared-event`); - return response; + return DiscussionConfirmResponse.parse(response); }, }; \ No newline at end of file diff --git a/frontend/src/features/discussion/api/invitationApi.ts b/frontend/apps/client/src/features/discussion/api/invitationApi.ts similarity index 94% rename from frontend/src/features/discussion/api/invitationApi.ts rename to frontend/apps/client/src/features/discussion/api/invitationApi.ts index 9523c2f5..119423b7 100644 --- a/frontend/src/features/discussion/api/invitationApi.ts +++ b/frontend/apps/client/src/features/discussion/api/invitationApi.ts @@ -1,4 +1,4 @@ -import { request } from '@/utils/fetch'; +import { request } from '@utils/fetch'; import { InvitationJoinResponseSchema, InvitationResponseSchema } from '../model/invitation'; diff --git a/frontend/src/features/discussion/api/keys.ts b/frontend/apps/client/src/features/discussion/api/keys.ts similarity index 100% rename from frontend/src/features/discussion/api/keys.ts rename to frontend/apps/client/src/features/discussion/api/keys.ts diff --git a/frontend/src/features/discussion/api/mutations.ts b/frontend/apps/client/src/features/discussion/api/mutations.ts similarity index 64% rename from frontend/src/features/discussion/api/mutations.ts rename to frontend/apps/client/src/features/discussion/api/mutations.ts index e4260971..d8afe6c2 100644 --- a/frontend/src/features/discussion/api/mutations.ts +++ b/frontend/apps/client/src/features/discussion/api/mutations.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { personalEventKeys } from '@/features/my-calendar/api/keys'; +import { sharedScheduleKey } from '@/features/shared-schedule/api/keys'; import type { DiscussionConfirmRequest, DiscussionRequest } from '../model'; import { candidateApi, discussionApi } from '.'; @@ -45,9 +45,12 @@ export const useDiscussionConfirmMutation = () => { id: string; body: DiscussionConfirmRequest; }) => candidateApi.postDiscussionConfirm({ id, body }), - onSuccess: ({ discussionId }) => { - queryClient.invalidateQueries({ - queryKey: personalEventKeys.all, + onSuccess: async ({ discussionId }) => { + await queryClient.invalidateQueries({ + queryKey: sharedScheduleKey, + }); + await queryClient.refetchQueries({ + queryKey: sharedScheduleKey, }); navigate({ to: '/discussion/confirm/$id', @@ -56,5 +59,29 @@ export const useDiscussionConfirmMutation = () => { }, }); + return { mutate }; +}; + +export const useDiscussionRestartMutation = (id: string) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const { mutate } = useMutation({ + mutationFn: () => discussionApi.postRestart(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: sharedScheduleKey, + }); + await queryClient.refetchQueries({ + queryKey: sharedScheduleKey, + }); + + navigate({ + to: '/discussion/$id', + params: { id }, + }); + }, + }); + return { mutate }; }; \ No newline at end of file diff --git a/frontend/src/features/discussion/api/prefetch.ts b/frontend/apps/client/src/features/discussion/api/prefetch.ts similarity index 100% rename from frontend/src/features/discussion/api/prefetch.ts rename to frontend/apps/client/src/features/discussion/api/prefetch.ts diff --git a/frontend/src/features/discussion/api/queries.ts b/frontend/apps/client/src/features/discussion/api/queries.ts similarity index 96% rename from frontend/src/features/discussion/api/queries.ts rename to frontend/apps/client/src/features/discussion/api/queries.ts index 39d3640e..84a49ddc 100644 --- a/frontend/src/features/discussion/api/queries.ts +++ b/frontend/apps/client/src/features/discussion/api/queries.ts @@ -88,9 +88,9 @@ export const useDiscussionRankQuery = ( export const useDiscussionParticipantsQuery = (discussionId: string) => { const { data: participants, isPending } - = useQuery( - discussionParticipantQuery(discussionId), - ); + = useQuery( + discussionParticipantQuery(discussionId), + ); return { participants, isPending }; }; diff --git a/frontend/src/features/discussion/api/queryOptions.ts b/frontend/apps/client/src/features/discussion/api/queryOptions.ts similarity index 87% rename from frontend/src/features/discussion/api/queryOptions.ts rename to frontend/apps/client/src/features/discussion/api/queryOptions.ts index c82fdab3..4b4489d8 100644 --- a/frontend/src/features/discussion/api/queryOptions.ts +++ b/frontend/apps/client/src/features/discussion/api/queryOptions.ts @@ -1,3 +1,5 @@ +import { keepPreviousData } from '@tanstack/react-query'; + import type { DiscussionCalendarRequest } from '../model'; import { candidateApi } from '.'; import { invitationApi } from './invitationApi'; @@ -14,4 +16,5 @@ export const discussionCalenderQueryOptions = ( queryKey: candidateKeys.calendar(discussionId, body), queryFn: () => candidateApi.postCalendarCandidate(discussionId, body), gcTime: gcTime, + placeholderData: keepPreviousData, }); \ No newline at end of file diff --git a/frontend/src/features/discussion/model/index.ts b/frontend/apps/client/src/features/discussion/model/index.ts similarity index 83% rename from frontend/src/features/discussion/model/index.ts rename to frontend/apps/client/src/features/discussion/model/index.ts index 5989fe1e..cbad440d 100644 --- a/frontend/src/features/discussion/model/index.ts +++ b/frontend/apps/client/src/features/discussion/model/index.ts @@ -1,6 +1,7 @@ +import { DATE_BAR, PASSWORD, TIME } from '@constants/regex'; +import { EndolphinDate } from '@endolphin/date-time'; import { z } from 'zod'; -import { DATE_BAR, PASSWORD, TIME } from '@/constants/regex'; import { UserDTO } from '@/features/user/model'; const MeetingMethodENUM = z.enum(['OFFLINE', 'ONLINE']); @@ -13,8 +14,8 @@ const DiscussionDTO = z.object({ const SharedEventDTO = z.object({ id: z.number(), - startDateTime: z.string().datetime(), - endDateTime: z.string().datetime(), + startDateTime: z.string().datetime({ local: true }), + endDateTime: z.string().datetime({ local: true }), }); const DiscussionRequest = z.object({ @@ -76,11 +77,15 @@ const DiscussionRankResponse = z.object({ eventsRankedOfTime: z.array(DiscussionDTO), }); -const DiscussionConfirmResponse = z.object({ +export const DiscussionConfirmResponse = z.object({ discussionId: z.number(), title: z.string(), - meetingMethodOrLocation: z.string(), - sharedEventDto: SharedEventDTO, + meetingMethodOrLocation: z.string().nullable(), + sharedEventDto: SharedEventDTO.transform((event) => ({ + id: event.id, + startDateTime: new EndolphinDate(event.startDateTime), + endDateTime: new EndolphinDate(event.endDateTime), + })), participantPictureUrls: z.array(z.string()), }); @@ -97,4 +102,4 @@ export type DiscussionRankRequest = z.infer; export type DiscussionRankResponse = z.infer; export type MeetingMethodENUM = z.infer; -export type DiscussionDTO = z.infer; \ No newline at end of file +export type DiscussionDTO = z.infer; diff --git a/frontend/src/features/discussion/model/invitation.ts b/frontend/apps/client/src/features/discussion/model/invitation.ts similarity index 88% rename from frontend/src/features/discussion/model/invitation.ts rename to frontend/apps/client/src/features/discussion/model/invitation.ts index c935a315..535a49d5 100644 --- a/frontend/src/features/discussion/model/invitation.ts +++ b/frontend/apps/client/src/features/discussion/model/invitation.ts @@ -1,7 +1,6 @@ +import { zCoerceToDate, zCoerceToTime } from '@utils/zod'; import { z } from 'zod'; -import { zCoerceToDate, zCoerceToTime } from '@/utils/zod'; - export const InvitationResponseSchema = z.object({ host: z.string(), title: z.string(), @@ -12,6 +11,7 @@ export const InvitationResponseSchema = z.object({ duration: z.number(), isFull: z.boolean(), requirePassword: z.boolean(), + timeUnlocked: z.union([zCoerceToDate, z.null()]), }); export const InvitationJoinRequestSchema = z.object({ diff --git a/frontend/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx index a3e7a007..50c5fff5 100644 --- a/frontend/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarDate.tsx @@ -1,7 +1,7 @@ -import { Flex } from '@/components/Flex'; -import { WEEK } from '@/constants/date'; -import { isSameDate } from '@/utils/date'; +import { WEEK } from '@constants/date'; +import { isSameDate } from '@endolphin/core/utils'; +import { Flex } from '@endolphin/ui'; import type { DiscussionDTO } from '../../model'; import DiscussionCard from '../DiscussionCard'; diff --git a/frontend/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx similarity index 84% rename from frontend/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx index 2173687a..3ac2f3c8 100644 --- a/frontend/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/CalendarTable.tsx @@ -1,13 +1,12 @@ +import { WEEK } from '@constants/date'; +import { useCalendarContext } from '@endolphin/calendar'; +import { formatDateToBarString, formatDateToWeekRange } from '@endolphin/core/utils'; +import { Flex } from '@endolphin/ui'; import { useParams } from '@tanstack/react-router'; import { useAtomValue } from 'jotai'; -import { useCalendarContext } from '@/components/Calendar/context/CalendarContext'; -import { Flex } from '@/components/Flex'; -import { WEEK } from '@/constants/date'; import { checkboxAtom } from '@/store/discussion'; -import { formatDateToWeekRange } from '@/utils/date'; -import { formatDateToBarString } from '@/utils/date/format'; import { useDiscussionCalendarQuery } from '../../api/queries'; import type { DiscussionDTO } from '../../model'; diff --git a/frontend/src/features/discussion/ui/DiscussionCalendar/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.css.ts similarity index 84% rename from frontend/src/features/discussion/ui/DiscussionCalendar/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.css.ts index b9e1718e..39251db3 100644 --- a/frontend/src/features/discussion/ui/DiscussionCalendar/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.css.ts @@ -1,9 +1,7 @@ +import { animation, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { fadeHighlightGrayProps } from '@/theme/animation.css'; -import { vars } from '@/theme/index.css'; - export const calendarTableStyle = style({ borderBottomLeftRadius: vars.radius[600], borderBottomRightRadius: vars.radius[600], @@ -32,7 +30,7 @@ export const dayStyle = recipe({ variants: { selected: { true: { - ...fadeHighlightGrayProps, + ...animation.fadeHighlightGrayProps, }, false: {}, }, diff --git a/frontend/src/features/discussion/ui/DiscussionCalendar/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.tsx similarity index 79% rename from frontend/src/features/discussion/ui/DiscussionCalendar/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.tsx index 15330529..bc1895ee 100644 --- a/frontend/src/features/discussion/ui/DiscussionCalendar/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCalendar/index.tsx @@ -1,4 +1,4 @@ -import { Calendar } from '@/components/Calendar'; +import { Calendar } from '@endolphin/calendar'; import { CalendarTable } from './CalendarTable'; diff --git a/frontend/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx index 4c80a809..e8ed68e9 100644 --- a/frontend/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionLarge.tsx @@ -1,8 +1,6 @@ -import { Flex } from '@/components/Flex'; -import { CalendarCheck, Clock, UserTwo } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { formatDateToString, formatDateToTimeString } from '@/utils/date/format'; +import { formatDateToString, formatDateToTimeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; import type { DiscussionDTO } from '../../model'; import { largeContainerStyle, rankContainerStyle, textStyle } from './card.css'; @@ -27,7 +25,7 @@ const DiscussionContents = ( color={vars.color.Ref.Netural[600]} typo='b2R' > - + {formatUserListToString(discussion.usersForAdjust)} - + {formatDateToString(new Date(discussion.startDateTime))} - + {formatDateToTimeString(new Date(discussion.startDateTime))} {' '} - diff --git a/frontend/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx similarity index 86% rename from frontend/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx index a8d4fff7..ec1c6b7c 100644 --- a/frontend/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/DiscussionSmall.tsx @@ -1,8 +1,6 @@ -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { formatDateToTimeString } from '@/utils/date/format'; +import { formatDateToTimeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Chip, Flex, Text } from '@endolphin/ui'; import type { DiscussionDTO } from '../../model'; import { chipContainerStyle, containerStyle } from './card.css'; diff --git a/frontend/src/features/discussion/ui/DiscussionCard/card.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/card.css.ts similarity index 93% rename from frontend/src/features/discussion/ui/DiscussionCard/card.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionCard/card.css.ts index c50fbe79..84fc9574 100644 --- a/frontend/src/features/discussion/ui/DiscussionCard/card.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/card.css.ts @@ -1,10 +1,10 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - -export const linkStyle = style({ +export const cardWrapperStyle = style({ width: '100%', + cursor: 'pointer', }); export const containerStyle = recipe({ diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionCard/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/index.tsx new file mode 100644 index 00000000..8b160d88 --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionCard/index.tsx @@ -0,0 +1,27 @@ +import { useNavigateToCandidate } from '@hooks/useNavigateToCandidate'; + +import type { DiscussionDTO } from '../../model'; +import { cardWrapperStyle } from './card.css'; +import { DiscussionLarge } from './DiscussionLarge'; +import { DiscussionSmall } from './DiscussionSmall'; + +interface DiscussionCardProps { + size: 'sm' | 'lg'; + discussion: DiscussionDTO; + rank?: number; +} + +const DiscussionCard = ({ size, discussion, rank }: DiscussionCardProps) => { + const { handleNavigateToCandidate } = useNavigateToCandidate(discussion); + + return ( +
+ {size === 'lg' ? + + : + } +
+ ); +}; + +export default DiscussionCard; \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionConfirmButton/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmButton/index.tsx similarity index 70% rename from frontend/src/features/discussion/ui/DiscussionConfirmButton/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionConfirmButton/index.tsx index 1c2e9b80..42e2e3b1 100644 --- a/frontend/src/features/discussion/ui/DiscussionConfirmButton/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmButton/index.tsx @@ -1,9 +1,6 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { Button } from '@endolphin/ui'; import { useParams } from '@tanstack/react-router'; -import Button from '@/components/Button'; -import { ongoingQueryKey, upcomingQueryKey } from '@/features/shared-schedule/api/keys'; - import { useDiscussionConfirmMutation } from '../../api/mutations'; import { useDiscussionHostQuery } from '../../api/queries'; import type { DiscussionDTO } from '../../model'; @@ -14,14 +11,11 @@ const DiscussionConfirmButton = ( const param: { id: string } = useParams({ from: '/_main/discussion/candidate/$id' }); const { isHost, isPending } = useDiscussionHostQuery(param.id); const { mutate } = useDiscussionConfirmMutation(); - const queryClient = useQueryClient(); if (isPending || !isHost) return null; const handleClickConfirm = () => { if (!param.id) return; - queryClient.invalidateQueries({ queryKey: upcomingQueryKey }); - queryClient.invalidateQueries({ queryKey: ongoingQueryKey.all }); mutate({ id: param.id, body: { startDateTime, endDateTime }, diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx new file mode 100644 index 00000000..a3bfdb92 --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx @@ -0,0 +1,35 @@ +import { type EndolphinDate, EndolphinTime } from '@endolphin/date-time'; +import { Badge, Flex } from '@endolphin/ui'; + +import { badgeContainerStyle } from './index.css'; + +const BadgeContainer = ({ + startDateTime, + endDateTime, + location, +}: { + startDateTime: EndolphinDate; + endDateTime: EndolphinDate; + location: string | null; +}) => { + const startTime = new EndolphinTime(startDateTime); + const endTime = new EndolphinTime(endDateTime); + return ( + + {startDateTime.formatDateToString()} + {startTime.getTimeRangeString(endTime)} + + {startTime.getMinuteDiff(endTime)} + 분 + + {location && {location}} + + ); +}; + +export default BadgeContainer; diff --git a/frontend/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx similarity index 86% rename from frontend/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx index 1f282d7c..ac5ba43c 100644 --- a/frontend/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/WarnModal.tsx @@ -1,6 +1,6 @@ -import Button from '@/components/Button'; -import { Modal } from '@/components/Modal'; -import type { ModalInfo } from '@/hooks/useModal'; +import { Modal } from '@components/Modal'; +import { Button } from '@endolphin/ui'; +import type { ModalInfo } from '@hooks/useModal'; import { modalFooterStyle } from './warnModal.css'; diff --git a/frontend/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts similarity index 93% rename from frontend/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts index ce577c41..a74845c2 100644 --- a/frontend/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const modalContainerStyle = style({ position: 'static', transform: 'translate(0, 0)', diff --git a/frontend/src/features/discussion/ui/DiscussionConfirmCard/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.tsx similarity index 72% rename from frontend/src/features/discussion/ui/DiscussionConfirmCard/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.tsx index ca77f1be..649afd38 100644 --- a/frontend/src/features/discussion/ui/DiscussionConfirmCard/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionConfirmCard/index.tsx @@ -1,12 +1,10 @@ +import { Modal } from '@components/Modal'; +import { Avatar, Button, Flex } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Avatar from '@/components/Avatar'; -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { Modal } from '@/components/Modal'; - import type { DiscussionConfirmResponse } from '../../model'; +import { DiscussionRestartButton } from '../DiscussionRestartButton'; import BadgeContainer from './BadgeContainer'; import { avatarWrapperStyle, @@ -26,9 +24,9 @@ const DiscussionConfirmCard = ( > ( <> - + - - - ) + 삭제하기 + + + ); }; @@ -83,4 +80,4 @@ const invalidateScheduleCaches = (queryClient: QueryClient) => { queryClient.invalidateQueries({ queryKey: ongoingQueryKey.all }); }; -export default FormButton; \ No newline at end of file +export default FormButton; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/FormContext.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormContext.ts similarity index 68% rename from frontend/src/features/discussion/ui/DiscussionForm/FormContext.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormContext.ts index 97aa629b..08a70c8f 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/FormContext.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormContext.ts @@ -1,8 +1,7 @@ +import { useSafeContext } from '@endolphin/core/hooks'; +import type { FormState } from '@hooks/useFormState'; import { createContext } from 'react'; -import type { FormState } from '@/hooks/useFormState'; -import { useSafeContext } from '@/hooks/useSafeContext'; - import type { DiscussionRequest } from '../../model'; export const FormContext = createContext | null>(null); diff --git a/frontend/src/features/discussion/ui/DiscussionForm/FormProvider.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormProvider.tsx similarity index 91% rename from frontend/src/features/discussion/ui/DiscussionForm/FormProvider.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormProvider.tsx index aeb0009f..45330674 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/FormProvider.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/FormProvider.tsx @@ -1,7 +1,6 @@ +import { useFormState } from '@hooks/useFormState'; import type { PropsWithChildren } from 'react'; -import { useFormState } from '@/hooks/useFormState'; - import type { DiscussionRequest } from '../../model'; import { FormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx index 64fcdc9e..1c6f070c 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDateDropdowns.tsx @@ -1,9 +1,7 @@ -import DatePicker from '@/components/DatePicker'; -import Input from '@/components/Input'; -import { useMonthNavigation } from '@/hooks/useDatePicker/useMonthNavigation'; -import { isSameDate } from '@/utils/date'; -import { formatDateToBarString } from '@/utils/date/format'; +import { DatePicker, useMonthNavigation } from '@endolphin/calendar'; +import { formatDateToBarString, isSameDate } from '@endolphin/core/utils'; +import { Input } from '@endolphin/ui'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx index 46e5e82e..65ccd5ad 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDeadlineDropdown.tsx @@ -1,8 +1,6 @@ -import DatePicker from '@/components/DatePicker'; -import Input from '@/components/Input'; -import { useMonthNavigation } from '@/hooks/useDatePicker/useMonthNavigation'; -import { isSameDate } from '@/utils/date'; -import { formatDateToBarString } from '@/utils/date/format'; +import { DatePicker, useMonthNavigation } from '@endolphin/calendar'; +import { formatDateToBarString, isSameDate } from '@endolphin/core/utils'; +import { Input } from '@endolphin/ui'; import type { DiscussionRequest } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx similarity index 89% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx index 31dbcc0d..b0c95382 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingDurationDropdown.tsx @@ -1,10 +1,8 @@ +import { MINUTES_HALF } from '@constants/date'; +import { formatTimeStringToNumber } from '@endolphin/core/utils'; +import { Dropdown, Input } from '@endolphin/ui'; import type { ChangeEvent } from 'react'; -import { Dropdown } from '@/components/Dropdown'; -import Input from '@/components/Input'; -import { MINUTES_HALF } from '@/constants/date'; -import { formatTimeStringToNumber } from '@/utils/date/format'; - import type { DiscussionRequest } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx similarity index 93% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx index 27ceec5c..c25d5f1d 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingLocation.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/Input'; +import { Input } from '@endolphin/ui'; import type { DiscussionRequest } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx similarity index 93% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx index 9b109b6f..69cd4cd2 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingMethodDropdown.tsx @@ -1,8 +1,6 @@ +import { Dropdown, Input } from '@endolphin/ui'; import type { ChangeEvent } from 'react'; -import { Dropdown } from '@/components/Dropdown'; -import Input from '@/components/Input'; - import type { DiscussionRequest, MeetingMethodENUM } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx similarity index 91% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx index bf578356..c0b9f5dd 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingPassword.tsx @@ -1,5 +1,5 @@ -import Input from '@/components/Input'; -import { PASSWORD } from '@/constants/regex'; +import { PASSWORD } from '@constants/regex'; +import { Input } from '@endolphin/ui'; import type { DiscussionRequest } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx similarity index 92% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx index 8dfe6653..bc85a05b 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTimeDropdowns.tsx @@ -1,13 +1,11 @@ -import type { ChangeEvent } from 'react'; - -import { Dropdown } from '@/components/Dropdown'; -import Input from '@/components/Input'; -import { MINUTES_HALF } from '@/constants/date'; +import { MINUTES_HALF } from '@constants/date'; import { formatMinutesToTimeString, formatNumberToTimeString, formatTimeStringToNumber, -} from '@/utils/date/format'; +} from '@endolphin/core/utils'; +import { Dropdown, Input } from '@endolphin/ui'; +import type { ChangeEvent } from 'react'; import type { DiscussionRequest } from '../../model'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx similarity index 95% rename from frontend/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx index 583540c5..2f912600 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/MeetingTitle.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/Input'; +import { Input } from '@endolphin/ui'; import { useFormContext } from './FormContext'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/index.css.ts similarity index 100% rename from frontend/src/features/discussion/ui/DiscussionForm/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/index.css.ts diff --git a/frontend/src/features/discussion/ui/DiscussionForm/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/index.tsx similarity index 94% rename from frontend/src/features/discussion/ui/DiscussionForm/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/index.tsx index 1aaf2000..16c110dd 100644 --- a/frontend/src/features/discussion/ui/DiscussionForm/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/index.tsx @@ -1,5 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { formatDateToBarString } from '@/utils/date/format'; +import { formatDateToBarString } from '@endolphin/core/utils'; +import { Flex } from '@endolphin/ui'; import type { DiscussionRequest } from '../../model'; import FormButton from './FormButton'; diff --git a/frontend/src/features/discussion/ui/DiscussionForm/type.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionForm/type.ts similarity index 100% rename from frontend/src/features/discussion/ui/DiscussionForm/type.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionForm/type.ts diff --git a/frontend/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx index 6699e2c6..1025f5ff 100644 --- a/frontend/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/Badges.tsx @@ -1,6 +1,5 @@ -import { Badge } from '@/components/Badge'; -import { Flex } from '@/components/Flex'; -import { getDateRangeString, getTimeRangeString } from '@/utils/date'; +import { getDateRangeString, getTimeRangeString } from '@endolphin/core/utils'; +import { Badge, Flex } from '@endolphin/ui'; import type { DiscussionInviteCardProps } from '.'; import { badgeContainerStyle } from './index.css'; diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/SubmitForm.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/SubmitForm.tsx new file mode 100644 index 00000000..a2c54857 --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/SubmitForm.tsx @@ -0,0 +1,100 @@ +import { MINUTE_IN_MILLISECONDS } from '@endolphin/core/utils'; +import { Button, Flex } from '@endolphin/ui'; +import { useNavigate } from '@tanstack/react-router'; +import { HTTPError } from '@utils/error'; +import { useState } from 'react'; + +import { addNoti } from '@/store/global/notification'; + +import { useInvitationJoinMutation } from '../../api/mutations'; +import { inputStyle } from './index.css'; +import TimerButton from './TimerButton'; + +interface SubmitFormProps { + discussionId: number; + canJoin: boolean; + requirePW: boolean; + unlockDateTime: Date | null; +} + +const LOCK_TIME_IN_MINUTES = 5; +const MAX_FAILED_ATTEMPTS = 5; +const LOCK_TIME_IN_MILLISECONDS = LOCK_TIME_IN_MINUTES * MINUTE_IN_MILLISECONDS; + +const SubmitForm = ({ discussionId, requirePW, canJoin, unlockDateTime }: SubmitFormProps) => { + const navigate = useNavigate(); + const { mutate } = useInvitationJoinMutation(); + const [password, setPassword] = useState(''); + const [unlockDT, setUnlockDT] = useState(unlockDateTime); + const handleJoinClick = () => { + mutate( + { body: { discussionId, password: password === '' ? undefined : password } }, + { onSuccess: (data) => { + if (data.isSuccess) { + navigate({ to: '/discussion/$id', params: { id: discussionId.toString() } }); + } else if (data.failedCount < 5) { + addNoti(passwordIncorrectNotiProps(data.failedCount)); + } else { + setUnlockDT(new Date(Date.now() + LOCK_TIME_IN_MILLISECONDS)); + addNoti(passwordLockNotiProps); + } + }, + onError: (error: Error) => { + if (error instanceof HTTPError && error.isTooManyRequestsError()) { + setUnlockDT(new Date(Date.now() + LOCK_TIME_IN_MILLISECONDS)); + } + } }); + }; + return ( + + {requirePW && canJoin && } + setUnlockDT(null)} + /> + + ); +}; + +const passwordIncorrectNotiProps = (failedCount: number) => ({ + type: 'error' as const, + title: `비밀번호가 일치하지 않습니다 - ${MAX_FAILED_ATTEMPTS - failedCount}회 남음`, + description: + `비밀번호를 ${MAX_FAILED_ATTEMPTS}회 틀릴 시 ${LOCK_TIME_IN_MINUTES}분간 초대를 수락할 수 없게 되니 주의해주세요!`, +}); + +const passwordLockNotiProps = { + type: 'error' as const, + title: `비밀번호를 ${MAX_FAILED_ATTEMPTS}회 틀려 ${LOCK_TIME_IN_MINUTES}분간 잠금됩니다.`, +}; + +const PasswordInput = ({ onChange }: { onChange: (password: string) => void }) => ( + onChange(e.target.value)} + placeholder='숫자 4~6자리 비밀번호' + /> +); + +const JoinButton = ({ canJoin, onClick, initialUnlockDateTime, onTimeEnd }: { + canJoin: boolean; + onClick: () => void; + onTimeEnd: () => void; + initialUnlockDateTime: Date | null; +}) => ( + initialUnlockDateTime === null ? + + : + +); + +export default SubmitForm; + diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/TimerButton.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/TimerButton.tsx new file mode 100644 index 00000000..76082faa --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/TimerButton.tsx @@ -0,0 +1,24 @@ +import { Button } from '@endolphin/ui'; +import useCountdown from '@hooks/useCountdown'; + +import { timerButtonStyle } from './index.css'; + +interface TimerButtonProps { + targetDateTime: Date; + onTimeEnd: () => void; +} + +const TimerButton = ({ targetDateTime, onTimeEnd }: TimerButtonProps) => { + const remainingTime = useCountdown(targetDateTime, onTimeEnd); + return ( + + ); +}; + +export default TimerButton; diff --git a/frontend/src/features/discussion/ui/DiscussionInviteCard/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.css.ts similarity index 89% rename from frontend/src/features/discussion/ui/DiscussionInviteCard/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.css.ts index d67e3e8b..d0004ff9 100644 --- a/frontend/src/features/discussion/ui/DiscussionInviteCard/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.css.ts @@ -1,9 +1,7 @@ +import { font, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { font } from '@/theme/font'; -import { vars } from '@/theme/index.css'; - export const modalContentsStyle = style({ display: 'flex', flexDirection: 'column', @@ -56,4 +54,9 @@ export const inputStyle = style({ fontSize: font['B3 (M)'].fontSize, fontWeight: font['B3 (M)'].fontWeight, }, +}); + +export const timerButtonStyle = style({ + justifyContent: 'center', + width: 126.77, }); \ No newline at end of file diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.tsx new file mode 100644 index 00000000..ba90e4bc --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionInviteCard/index.tsx @@ -0,0 +1,84 @@ + +import { Modal } from '@components/Modal'; +import type { Time } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Text } from '@endolphin/ui'; + +import Badges from './Badges'; +import { + modalContentsStyle, + modalFooterStyle, +} from './index.css'; +import SubmitForm from './SubmitForm'; + +export interface DiscussionInviteCardProps { + discussionId: number; + hostName: string; + title: string; + canJoin: boolean; + timeUnlocked: Date | null; + // Badge props + dateRange: { start: Date; end: Date }; + timeRange: { start: Time; end: Time }; + meetingDuration: number; + requirePassword: boolean; + location?: string; +} + +// TODO: Input 입력 숫자 4-6자리로 제한 + +const DiscussionInviteCard = ({ + discussionId, hostName, title, canJoin, requirePassword, timeUnlocked, ...badgeProps +}: DiscussionInviteCardProps) => ( + + + + +); + +export default DiscussionInviteCard; + +interface DiscussionInviteCardContentsProps { + dateRange: { start: Date; end: Date }; + timeRange: { start: Time; end: Time }; + meetingDuration: number; + location?: string; +} +const DiscussionInviteCardContents = (props: DiscussionInviteCardContentsProps) => ( + + + +); + +interface DiscussionInviteCardFooterProps { + canJoin: boolean; + discussionId: number; + requirePassword: boolean; + timeUnlocked: Date | null; +} +const DiscussionInviteCardFooter = ({ + canJoin, requirePassword, discussionId, timeUnlocked, +}: DiscussionInviteCardFooterProps) => ( + + {!canJoin && ( + + 인원이 꽉 찼어요 + + )} + + +); diff --git a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx similarity index 70% rename from frontend/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx index 571d6cb8..90069378 100644 --- a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/Title.tsx @@ -1,7 +1,4 @@ -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { useGroupContext } from '@/components/Group/GroupContext'; -import { Text } from '@/components/Text'; +import { Button, Flex, Text, useGroupContext } from '@endolphin/ui'; const Title = () => { const groupContext = useGroupContext(); diff --git a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts similarity index 85% rename from frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts index fd41c91c..6bb400fb 100644 --- a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const checkboxContainerStyle = style({ flexShrink: 0, padding: `${vars.spacing[600]} ${vars.spacing[500]}`, diff --git a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx similarity index 91% rename from frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx index ca38375d..7db639d5 100644 --- a/frontend/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionMemberCheckbox/index.tsx @@ -1,13 +1,10 @@ +import { Button, Checkbox, Flex, Group } from '@endolphin/ui'; +import { useGroup } from '@hooks/useGroup'; import { useQueryClient } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; import { useAtom } from 'jotai'; import { useEffect, useMemo } from 'react'; -import Button from '@/components/Button'; -import { Checkbox } from '@/components/Checkbox'; -import { Flex } from '@/components/Flex'; -import { Group } from '@/components/Group'; -import { useGroup } from '@/hooks/useGroup'; import { checkboxAtom } from '@/store/discussion'; import { candidateKeys } from '../../api/keys'; diff --git a/frontend/src/features/discussion/ui/DiscussionRank/RankContents.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankContents.tsx similarity index 87% rename from frontend/src/features/discussion/ui/DiscussionRank/RankContents.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankContents.tsx index 772846be..d6482e2c 100644 --- a/frontend/src/features/discussion/ui/DiscussionRank/RankContents.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankContents.tsx @@ -1,5 +1,4 @@ -import { Flex } from '@/components/Flex'; -import { Calendar } from '@/components/Icon'; +import { Flex, Icon } from '@endolphin/ui'; import type { DiscussionDTO } from '../../model'; import DiscussionCard from '../DiscussionCard'; @@ -28,7 +27,7 @@ export const RankContents = ({ data }: { data: DiscussionDTO[] }) => { size='lg' />)}
- +
diff --git a/frontend/src/features/discussion/ui/DiscussionRank/RankTable.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTable.tsx similarity index 93% rename from frontend/src/features/discussion/ui/DiscussionRank/RankTable.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTable.tsx index 245f2624..d567e312 100644 --- a/frontend/src/features/discussion/ui/DiscussionRank/RankTable.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTable.tsx @@ -1,5 +1,4 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; +import { Flex, Text } from '@endolphin/ui'; import type { DiscussionDTO } from '../../model'; import { diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx new file mode 100644 index 00000000..56363b77 --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx @@ -0,0 +1,81 @@ +import { formatDateToString, formatDateToTimeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Chip, Flex, Text } from '@endolphin/ui'; +import { useNavigateToCandidate } from '@hooks/useNavigateToCandidate'; + +import type { DiscussionDTO } from '../../model'; +import { + tableCellStyle, + tableCellTextStyle, + tableRowStyle, +} from './index.css'; + +const RankAdjustable = ({ users }: { users: DiscussionDTO['usersForAdjust'] }) => { + const ADJUSTMENT_LENGTH = users.length; + const isRecommend = ADJUSTMENT_LENGTH === 0; + const AdjustmentText = () => { + if (isRecommend) return '모두 가능해요'; + return ( + <> + + {ADJUSTMENT_LENGTH} + 명 + + 만 조율하면 돼요 + + ); + }; + return ( + <> + + + + + {!isRecommend && users.map((user) => {user.name})} + + + ); +}; + +export const RankTableRow = ( + { discussion, rank }: { discussion: DiscussionDTO; rank: number }, +) => { + const { handleNavigateToCandidate } = useNavigateToCandidate(discussion); + return( + + + + {rank + 4} + + + + + + + + {formatDateToString(new Date(discussion.startDateTime))} + + + + + {formatDateToTimeString(new Date(discussion.startDateTime))} + {' '} + - + {' '} + {formatDateToTimeString(new Date(discussion.endDateTime))} + + + + ); +}; + \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionRank/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.css.ts similarity index 95% rename from frontend/src/features/discussion/ui/DiscussionRank/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.css.ts index 275c5694..d528a9f5 100644 --- a/frontend/src/features/discussion/ui/DiscussionRank/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const segmentControlStyle = style({ height: '100%', overflow: 'hidden', @@ -57,7 +56,10 @@ export const tableBodyStyle = style({ }); export const tableRowStyle = style({ + display: 'flex', + width: '100%', borderBottom: `1px solid ${vars.color.Ref.Netural[200]}`, + cursor: 'pointer', }); export const tableHeaderCellStyle = recipe({ @@ -122,4 +124,4 @@ export const tableCellStyle = recipe({ export const tableCellTextStyle = style({ paddingRight: '8rem', -}); \ No newline at end of file +}); diff --git a/frontend/src/features/discussion/ui/DiscussionRank/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.tsx similarity index 95% rename from frontend/src/features/discussion/ui/DiscussionRank/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.tsx index 78613243..0a00a9a2 100644 --- a/frontend/src/features/discussion/ui/DiscussionRank/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRank/index.tsx @@ -1,7 +1,7 @@ +import { SegmentControl } from '@endolphin/ui'; import { useParams } from '@tanstack/react-router'; import { useAtomValue } from 'jotai'; -import SegmentControl from '@/components/SegmentControl'; import { checkboxAtom } from '@/store/discussion'; import { useDiscussionRankQuery } from '../../api/queries'; diff --git a/frontend/apps/client/src/features/discussion/ui/DiscussionRestartButton/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionRestartButton/index.tsx new file mode 100644 index 00000000..0a5f8dda --- /dev/null +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionRestartButton/index.tsx @@ -0,0 +1,89 @@ +import { Button, Flex } from '@endolphin/ui'; +import { useParams } from '@tanstack/react-router'; +import type { ComponentProps } from 'react'; + +import { useGlobalModal } from '@/store/global/modal'; + +import { useDiscussionRestartMutation } from '../../api/mutations'; +import { useDiscussionHostQuery } from '../../api/queries'; + +type RestartType = 'upcoming' | 'confirm'; +type ButtonStyleType = ComponentProps['style']; + +export const DiscussionRestartButton = ({ type }: { type: RestartType }) => { + const typeMap: Record = { + upcoming: { path: '/_main/upcoming-schedule/$id', style: 'filled' }, + confirm: { path: '/_main/discussion/confirm/$id', style: 'borderless' }, + }; + + const { id } = useParams({ from: typeMap[type].path }); + const { createModal } = useGlobalModal(); + const { isHost } = useDiscussionHostQuery(id); + + if (!isHost) return null; + + const handleOpenModal = () => { + createModal({ + title: '일정 조율을 다시 진행하시겠습니까?', + subTitle: '경고', + description: , + type: 'error', + children: , + }); + }; + + return ( + + ); +}; + +const Description = () => ( + + 참여자들의 캘린더에서 확정되었던 일정이 삭제됩니다. +
+ 마감기한이 지났다면, 7일 연장해드릴게요! +
+); + +const RestartModalFooter = ({ id }: { id: string }) => { + const { onModalClose } = useGlobalModal(); + const { mutate } = useDiscussionRestartMutation(id); + const handleClickRestartDiscussion = () => { + mutate(); + onModalClose(); + }; + + return ( + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionTab/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.css.ts similarity index 89% rename from frontend/src/features/discussion/ui/DiscussionTab/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.css.ts index 43762ec6..5b44eabb 100644 --- a/frontend/src/features/discussion/ui/DiscussionTab/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const tabContainerStyle = style({ width: '100%', maxHeight: '100%', diff --git a/frontend/src/features/discussion/ui/DiscussionTab/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.tsx similarity index 96% rename from frontend/src/features/discussion/ui/DiscussionTab/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.tsx index 4bc68e8d..fdc4c6d0 100644 --- a/frontend/src/features/discussion/ui/DiscussionTab/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionTab/index.tsx @@ -1,6 +1,6 @@ +import { Tab } from '@endolphin/ui'; import { useAtom } from 'jotai'; -import { Tab } from '@/components/Tab'; import { tabAtom } from '@/store/discussion'; import DiscussionCalendar from '../DiscussionCalendar'; diff --git a/frontend/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx similarity index 79% rename from frontend/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx index 3a0e92c8..9c84448a 100644 --- a/frontend/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/DiscussionBadges.tsx @@ -1,7 +1,5 @@ -import { Badge } from '@/components/Badge'; -import { Flex } from '@/components/Flex'; -import { getDateRangeString } from '@/utils/date'; -import { formatTimeStringToLocaleString } from '@/utils/date/format'; +import { formatTimeStringToLocaleString, getDateRangeString } from '@endolphin/core/utils'; +import { Badge, Flex } from '@endolphin/ui'; import type { DiscussionResponse } from '../../model'; diff --git a/frontend/src/features/discussion/ui/DiscussionTitle/index.css.ts b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.css.ts similarity index 73% rename from frontend/src/features/discussion/ui/DiscussionTitle/index.css.ts rename to frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.css.ts index 6d0ea46f..537548a0 100644 --- a/frontend/src/features/discussion/ui/DiscussionTitle/index.css.ts +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const titleStyle = style({ padding: `${vars.spacing[700]} 0`, }); \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionTitle/index.tsx b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.tsx similarity index 87% rename from frontend/src/features/discussion/ui/DiscussionTitle/index.tsx rename to frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.tsx index 955e18c4..ebc2c9d8 100644 --- a/frontend/src/features/discussion/ui/DiscussionTitle/index.tsx +++ b/frontend/apps/client/src/features/discussion/ui/DiscussionTitle/index.tsx @@ -1,9 +1,7 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; import { useParams } from '@tanstack/react-router'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; - import { useDiscussionQuery } from '../../api/queries'; import { DiscussionBadges } from './DiscussionBadges'; import { titleStyle } from './index.css'; diff --git a/frontend/apps/client/src/features/login/api/index.ts b/frontend/apps/client/src/features/login/api/index.ts new file mode 100644 index 00000000..ff203a5e --- /dev/null +++ b/frontend/apps/client/src/features/login/api/index.ts @@ -0,0 +1,20 @@ +import { request } from '@utils/fetch'; + +import type { JWTResponse } from '../model'; + +export const loginApi = { + getJWT: async (code: string): Promise => { + const response = await request.post('/api/v1/login', { + body: { code }, + }); + return response; + }, + renewJWT: async (): Promise => { + const response = await request.post('/api/v1/refresh-token'); + return response; + }, + removeRefreshToken: async () => { + const response = await request.post('/api/v1/logout'); + return response; + }, +}; diff --git a/frontend/src/features/login/api/mutations.ts b/frontend/apps/client/src/features/login/api/mutations.ts similarity index 84% rename from frontend/src/features/login/api/mutations.ts rename to frontend/apps/client/src/features/login/api/mutations.ts index ce8bfb27..1ed1f5b2 100644 --- a/frontend/src/features/login/api/mutations.ts +++ b/frontend/apps/client/src/features/login/api/mutations.ts @@ -1,7 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; - -import { setLogin } from '@/utils/auth'; +import { accessTokenService } from '@utils/auth/accessTokenService'; import type { JWTRequest } from '../model'; import { loginApi } from '.'; @@ -16,7 +15,7 @@ export const useJWTMutation = () => { const { mutate } = useMutation({ mutationFn: ({ code }: JWTMutationProps) => loginApi.getJWT(code), onSuccess: (response, { lastPath }) => { - setLogin(response); + accessTokenService.setAccessToken(response); navigate({ to: lastPath || '/home', }); diff --git a/frontend/src/features/login/model/index.ts b/frontend/apps/client/src/features/login/model/index.ts similarity index 100% rename from frontend/src/features/login/model/index.ts rename to frontend/apps/client/src/features/login/model/index.ts diff --git a/frontend/src/features/login/ui/GoogleLoginButton.tsx b/frontend/apps/client/src/features/login/ui/GoogleLoginButton.tsx similarity index 70% rename from frontend/src/features/login/ui/GoogleLoginButton.tsx rename to frontend/apps/client/src/features/login/ui/GoogleLoginButton.tsx index a339812a..6635337c 100644 --- a/frontend/src/features/login/ui/GoogleLoginButton.tsx +++ b/frontend/apps/client/src/features/login/ui/GoogleLoginButton.tsx @@ -1,10 +1,8 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import { Flex } from '@/components/Flex'; -import googleLoginIcon from '@/components/Icon/png/google-login-icon.png'; -import { Text } from '@/components/Text'; import { serviceENV } from '@/envconfig'; -import { vars } from '@/theme/index.css'; import { googleLoginButtonStyle } from './index.css'; @@ -21,7 +19,7 @@ export const GoogleLoginButton = () => ( Google 로그인 아이콘 Google로 시작하기 diff --git a/frontend/src/features/login/ui/index.css.ts b/frontend/apps/client/src/features/login/ui/index.css.ts similarity index 86% rename from frontend/src/features/login/ui/index.css.ts rename to frontend/apps/client/src/features/login/ui/index.css.ts index f80091fb..4939245b 100644 --- a/frontend/src/features/login/ui/index.css.ts +++ b/frontend/apps/client/src/features/login/ui/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const googleLoginButtonStyle = style({ height: 40, padding: `0 ${vars.spacing[400]}`, diff --git a/frontend/src/features/my-calendar/api/hooks.ts b/frontend/apps/client/src/features/my-calendar/api/hooks.ts similarity index 58% rename from frontend/src/features/my-calendar/api/hooks.ts rename to frontend/apps/client/src/features/my-calendar/api/hooks.ts index 38bc9735..7d31da46 100644 --- a/frontend/src/features/my-calendar/api/hooks.ts +++ b/frontend/apps/client/src/features/my-calendar/api/hooks.ts @@ -1,6 +1,6 @@ -import type { FormValues } from '@/hooks/useFormRef'; +import type { FormValues } from '@hooks/useFormRef'; -import type { PersonalEventRequest } from '../model'; +import type { PersonalEventWithDateAndTime } from '../ui/SchedulePopover/PopoverContext'; import { usePersonalEventDeleteMutation, usePersonalEventMutation, @@ -16,24 +16,27 @@ export const useSchedulePopover = ({ setIsOpen: (isOpen: boolean) => void; reset?: () => void; scheduleId?: number; - valuesRef: { current: FormValues }; + valuesRef: { current: FormValues }; }) => { const { mutate: createMutate } = usePersonalEventMutation(); const { mutate: editMutate } = usePersonalEventUpdateMutation(); const { mutate: deleteMutate } = usePersonalEventDeleteMutation(); - + const createSchedule = () => { + const { startTime, startDate, endDate, endTime, ...values } = valuesRef.current; + const startDateTime = `${valuesRef.current.startDate}T${valuesRef.current.startTime}`; + const endDateTime = `${valuesRef.current.endDate}T${valuesRef.current.endTime}`; + return { ...values, startDateTime, endDateTime }; + }; const handleClickCreate = () => { - createMutate(valuesRef.current); + createMutate(createSchedule()); reset?.(); setIsOpen(false); }; - const handleClickEdit = () => { - if (scheduleId) editMutate({ id: scheduleId, body: valuesRef.current }); + if (scheduleId) editMutate({ id: scheduleId, body: createSchedule() }); reset?.(); setIsOpen(false); }; - const handleClickDelete = () => { if (scheduleId) deleteMutate({ id: scheduleId, @@ -42,7 +45,6 @@ export const useSchedulePopover = ({ reset?.(); setIsOpen(false); }; - return { handleClickCreate, handleClickEdit, handleClickDelete }; }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/api/index.ts b/frontend/apps/client/src/features/my-calendar/api/index.ts similarity index 72% rename from frontend/src/features/my-calendar/api/index.ts rename to frontend/apps/client/src/features/my-calendar/api/index.ts index fb35f4c5..8c1f520c 100644 --- a/frontend/src/features/my-calendar/api/index.ts +++ b/frontend/apps/client/src/features/my-calendar/api/index.ts @@ -1,6 +1,11 @@ -import { request } from '@/utils/fetch'; +import { request } from '@utils/fetch'; -import type { DateRangeParams, PersonalEventRequest, PersonalEventResponse } from '../model'; +import type { + CalendarListResponse, + DateRangeParams, + PersonalEventRequest, + PersonalEventResponse, +} from '../model'; export const personalEventApi = { getPersonalEvent: async ( @@ -26,4 +31,11 @@ export const personalEventApi = { params: { syncWithGoogleCalendar: syncWithGoogleCalendar.toString() }, }); }, +}; + +export const calendarApi = { + getCalendarList: async (): Promise => { + const response = await request.get('/api/v1/calendar/list'); + return response.data; + }, }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/api/keys.ts b/frontend/apps/client/src/features/my-calendar/api/keys.ts similarity index 76% rename from frontend/src/features/my-calendar/api/keys.ts rename to frontend/apps/client/src/features/my-calendar/api/keys.ts index da9d5af1..c970ff36 100644 --- a/frontend/src/features/my-calendar/api/keys.ts +++ b/frontend/apps/client/src/features/my-calendar/api/keys.ts @@ -4,4 +4,8 @@ export const personalEventKeys = { all: ['personalEvents'], detail: (data: DateRangeParams) => [...personalEventKeys.all, data], +}; + +export const calendarKeys = { + all: ['calendarList'], }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/api/mutations.ts b/frontend/apps/client/src/features/my-calendar/api/mutations.ts similarity index 100% rename from frontend/src/features/my-calendar/api/mutations.ts rename to frontend/apps/client/src/features/my-calendar/api/mutations.ts diff --git a/frontend/src/features/my-calendar/api/queries.ts b/frontend/apps/client/src/features/my-calendar/api/queries.ts similarity index 56% rename from frontend/src/features/my-calendar/api/queries.ts rename to frontend/apps/client/src/features/my-calendar/api/queries.ts index 0cbd3c21..42aca14f 100644 --- a/frontend/src/features/my-calendar/api/queries.ts +++ b/frontend/apps/client/src/features/my-calendar/api/queries.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import type { DateRangeParams, PersonalEventResponse } from '../model'; -import { personalEventApi } from '.'; -import { personalEventKeys } from './keys'; +import { calendarApi, personalEventApi } from '.'; +import { calendarKeys, personalEventKeys } from './keys'; export const usePersonalEventsQuery = (params: DateRangeParams) => { const { data: personalEvents, isPending } = useQuery({ @@ -11,4 +11,13 @@ export const usePersonalEventsQuery = (params: DateRangeParams) => { }); return { personalEvents, isPending }; +}; + +export const useCalendarListQuery = () => { + const { data: calendarList, isPending } = useQuery({ + queryKey: calendarKeys.all, + queryFn: calendarApi.getCalendarList, + }); + + return { calendarList, isPending }; }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/model/index.ts b/frontend/apps/client/src/features/my-calendar/model/index.ts similarity index 86% rename from frontend/src/features/my-calendar/model/index.ts rename to frontend/apps/client/src/features/my-calendar/model/index.ts index 77ca47d8..eaa1dd36 100644 --- a/frontend/src/features/my-calendar/model/index.ts +++ b/frontend/apps/client/src/features/my-calendar/model/index.ts @@ -15,10 +15,12 @@ const PersonalEventResponse = PersonalEventDTO.omit({ syncWithGoogleCalendar: tr const PersonalEventRequest = PersonalEventDTO.omit( { id: true, googleEventId: true, calendarId: true }, ); +const CalendarListResponse = z.object({ name: z.string() }); export type PersonalEventDTO = z.infer; export type PersonalEventResponse = z.infer; export type PersonalEventRequest = z.infer; +export type CalendarListResponse = z.infer; export interface DateRangeParams { startDate: string; diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/Card.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/Card.tsx similarity index 95% rename from frontend/src/features/my-calendar/ui/CalendarCard/Card.tsx rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/Card.tsx index 1dbd6879..0acb2fd6 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/Card.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/Card.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; import { CardBottom } from './CardBottom'; import { CardContents } from './CardContents'; diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx similarity index 66% rename from frontend/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx index ade8b2a3..17ba77ef 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardBottom.tsx @@ -1,5 +1,4 @@ -import { Flex } from '@/components/Flex'; -import { GoogleCalendar } from '@/components/Icon'; +import { Flex, Icon } from '@endolphin/ui'; import { cardBottomStyle } from './index.css'; @@ -12,6 +11,6 @@ export const CardBottom = () => ( justify='flex-end' width='full' > - + ); diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/CardContents.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardContents.tsx similarity index 80% rename from frontend/src/features/my-calendar/ui/CalendarCard/CardContents.tsx rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardContents.tsx index de5e1fcf..fc346d63 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/CardContents.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/CardContents.tsx @@ -1,9 +1,7 @@ -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { adjustmentStatusMap } from '@/constants/statusMap'; -import { vars } from '@/theme/index.css'; -import { formatDateToTimeString } from '@/utils/date/format'; +import { adjustmentStatusMap } from '@constants/statusMap'; +import { formatDateToTimeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Chip, Flex, Text } from '@endolphin/ui'; import { cardContentStyle, cardTextStyle, cardTitleStyle } from './index.css'; import type { CalendarCardProps } from './type'; @@ -12,7 +10,7 @@ const CalendarCardChip = ({ status, size }: Pick + {adjustmentStatusMap[status].label} ); diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.css.ts similarity index 98% rename from frontend/src/features/my-calendar/ui/CalendarCard/index.css.ts rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.css.ts index 4b57c519..2358e542 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/index.css.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const cardContainerStyle = recipe({ base: { padding: vars.spacing[200], diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.tsx similarity index 93% rename from frontend/src/features/my-calendar/ui/CalendarCard/index.tsx rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.tsx index 4dea2622..dda5ac6d 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/index.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/index.tsx @@ -1,7 +1,6 @@ +import { formatDateToDateTimeString } from '@endolphin/core/utils'; import { useState } from 'react'; -import { formatDateToDateTimeString } from '@/utils/date/format'; - import { SchedulePopover } from '../SchedulePopover'; import { Card } from './Card'; import type { CalendarCardProps } from './type'; diff --git a/frontend/src/features/my-calendar/ui/CalendarCard/type.ts b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/type.ts similarity index 82% rename from frontend/src/features/my-calendar/ui/CalendarCard/type.ts rename to frontend/apps/client/src/features/my-calendar/ui/CalendarCard/type.ts index c8196b72..04325f0d 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCard/type.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCard/type.ts @@ -1,7 +1,6 @@ +import type { AdjustmentStatus } from '@constants/statusMap'; import type { CSSProperties } from 'react'; -import type { AdjustmentStatus } from '@/constants/statusMap'; - export interface CalendarCardProps { id: number; status: AdjustmentStatus; diff --git a/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/DefaultCard.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/DefaultCard.tsx new file mode 100644 index 00000000..b98d4d22 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/DefaultCard.tsx @@ -0,0 +1,51 @@ +import { calcPositionByDate } from '@endolphin/core/utils'; +import type { EndolphinDate } from '@endolphin/date-time'; + +import { TIME_HEIGHT } from '@/constants/date'; + +import type { PersonalEventResponse } from '../../model'; +import { CalendarCard } from '../CalendarCard'; + +const calcSize = (height: number) => { + if (height < TIME_HEIGHT) return 'sm'; + if (height < TIME_HEIGHT * 2.5) return 'md'; + return 'lg'; +}; + +export const DefaultCard = ( + { card, start, end, idx }: + { card: PersonalEventResponse; start: EndolphinDate; end: EndolphinDate; idx: number }, +) => { + const LEFT_MARGIN = 24; + const RIGHT_MARGIN = 8; + const SIDEBAR_WIDTH = 72; + const TOP_GAP = 16; + const DAYS = 7; + const { x: sx, y: sy } = calcPositionByDate(start.getDate()); + const { y: ey } = calcPositionByDate(end.getDate()); + + if (sy === ey) return null; + + const height = ey - sy; + return ( + + ); +}; + \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.css.ts new file mode 100644 index 00000000..bac410fd --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.css.ts @@ -0,0 +1,18 @@ +import { recipe } from '@vanilla-extract/recipes'; + +export const cardListStyle = recipe({ + base: {}, + variants: { + disable: { + true: { + pointerEvents: 'none', + }, + false: { + pointerEvents: 'auto', + }, + }, + }, + defaultVariants: { + disable: false, + }, +}); \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.tsx new file mode 100644 index 00000000..bd162af5 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/CalendarCardList/index.tsx @@ -0,0 +1,52 @@ +import { isAllday } from '@endolphin/core/utils'; +import type { GroupInfo } from '@endolphin/date-time'; +import { EndolphinDate, groupByDate, groupByOverlap, sortDates } from '@endolphin/date-time'; + +import type { PersonalEventResponse } from '../../model'; +import { DefaultCard } from './DefaultCard'; +import { cardListStyle } from './index.css'; + +const createGroupInfo = (card: PersonalEventResponse): GroupInfo => { + const start = new EndolphinDate(card.startDateTime); + const end = new EndolphinDate(card.endDateTime); + const { year: sy, month: sm, day: sd } = start.getDateParts(); + const { year: ey, month: em, day: ed } = end.getDateParts(); + return { + id: card.id.toString(), + start, + end, + data: card, + sy, sm, sd, + ey, em, ed, + }; +}; + +export const CalendarCardList = ({ cards, isSelecting }: { + cards: PersonalEventResponse[]; + isSelecting: boolean; +}) => { + const isNotAlldayCards + = cards.filter((card)=>!isAllday(card.startDateTime, card.endDateTime)).map(createGroupInfo); + + return ( +
+ {groupByDate(isNotAlldayCards).map((dayCards) => + groupByOverlap(dayCards.sort((a, b)=> + sortDates( + { start: a.start, end: a.end }, + { start: b.start, end: b.end }, + ))) + .map(({ id, data, start, end, idx }) => ( + + ), + ), + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/CalendarAccountAddButton.tsx b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/CalendarAccountAddButton.tsx new file mode 100644 index 00000000..fb690fbb --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/CalendarAccountAddButton.tsx @@ -0,0 +1,13 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; +import { Link } from '@tanstack/react-router'; + +import { serviceENV } from '@/envconfig'; + +const CalendarAccountAddButton = () => ( + + + 캘린더 계정 추가 + +); + +export default CalendarAccountAddButton; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/LinkedCalendarList.tsx b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/LinkedCalendarList.tsx new file mode 100644 index 00000000..60e4b12b --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/LinkedCalendarList.tsx @@ -0,0 +1,55 @@ + +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; + +import type { CalendarListResponse } from '../../model'; +import { + cardStyle, + cardTextStyle, + emptyTextStyle, + googleCalendarIconWrapperStyle, +} from './index.css'; + +const EmptyCard = () => ( + + 연동된 캘린더가 없어요. + +); + +export const LinkedCalendarList = ({ calendarList }: { calendarList: CalendarListResponse[] }) => { + if (!calendarList.length) return ; + + return calendarList.map((calendarAccount) => ( + + )); +}; + +const LinkedCalendarItem = ({ calendarAccount }: { calendarAccount: string }) => ( +
+ +
+ +
+ + {calendarAccount} + +
+
+); diff --git a/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.css.ts new file mode 100644 index 00000000..fb888f3f --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.css.ts @@ -0,0 +1,40 @@ +import { vars } from '@endolphin/theme'; +import { style } from '@vanilla-extract/css'; + +export const titleContainerStyle = style({ + padding: `${vars.spacing[300]} ${vars.spacing[300]} ${vars.spacing[300]} ${vars.spacing[400]}`, + + borderRadius: vars.radius[300], + backgroundColor: vars.color.Ref.Primary[50], +}); + +export const cardStyle = style({ + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: vars.spacing[300], + + padding: vars.spacing[200], + + borderRadius: vars.radius[300], + border: `1px solid ${vars.color.Ref.Netural[100]}`, + backgroundColor: vars.color.Ref.Netural.White, +}); + +export const cardTextStyle = style({ + display: 'flex', + alignItems: 'center', + gap: vars.spacing[200], +}); + +export const emptyTextStyle = style({ + padding: `0 ${vars.spacing[400]}`, +}); + +export const googleCalendarIconWrapperStyle = style({ + display: 'flex', + backgroundColor: vars.color.Ref.Netural[50], + border: `0.5px solid ${vars.color.Ref.Netural[200]}`, + borderRadius: '0.25rem', + padding: '0.3125rem', +}); diff --git a/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.tsx new file mode 100644 index 00000000..0ff4ddac --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/LinkedCalendar/index.tsx @@ -0,0 +1,36 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; + +import { useCalendarListQuery } from '../../api/queries'; +import CalendarAccountAddButton from './CalendarAccountAddButton'; +import { titleContainerStyle } from './index.css'; +import { LinkedCalendarList } from './LinkedCalendarList'; + +const LinkedCalendar = () => { + const { calendarList, isPending } = useCalendarListQuery(); + if (isPending || !calendarList) return null; + + return ( + + + {/* TODO: npm 배포 및 버전 동기화 문제 해결 후 아이콘 변경 */} + + 연동된 캘린더 계정 + + + {!calendarList.length && } + + ); +}; + +export default LinkedCalendar; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx b/frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx new file mode 100644 index 00000000..5dc98b70 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx @@ -0,0 +1,30 @@ +import { useSharedCalendarContext } from '@endolphin/calendar'; +import { calcSizeByDate } from '@endolphin/core/utils'; + +import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; + +import { discussionBoxStyle } from './index.css'; + +export const CalendarDiscussionBox = () => { + const { selectedDateRange } = useDiscussionContext(); + const { selectedWeek } = useSharedCalendarContext(); + if (!selectedDateRange) return null; + + const sizePosition = calcSizeByDate(selectedDateRange, selectedWeek); + + if (!sizePosition) return null; + const { x, y, width, height } = sizePosition; + + return ( +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx b/frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx similarity index 79% rename from frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx rename to frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx index 23985154..a4d1146e 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx @@ -1,8 +1,9 @@ +import { Calendar } from '@endolphin/calendar'; +import { useSelectTime } from '@endolphin/core/hooks'; +import { formatDateToDateTimeString } from '@endolphin/core/utils'; import { useState } from 'react'; -import { Calendar } from '@/components/Calendar'; -import { useSelectTime } from '@/hooks/useSelectTime'; -import { formatDateToDateTimeString } from '@/utils/date/format'; +import { useTableContext } from '@/pages/MyCalendarPage/TableContext'; import type { PersonalEventResponse } from '../../model'; import { CalendarCardList } from '../CalendarCardList'; @@ -10,14 +11,13 @@ import { SchedulePopover } from '../SchedulePopover'; import { CalendarDiscussionBox } from './CalendarDiscussionBox'; import { CalendarTimeBar } from './CalendarTimeBar'; import { containerStyle } from './index.css'; -import { useScrollToCurrentTime } from './useScrollToCurrentTime'; const CalendarTable = ( { personalEvents = [] }: { personalEvents?: PersonalEventResponse[] }, ) => { const { handleMouseUp, reset, ...time } = useSelectTime(); + const { tableRef, height } = useTableContext(); const [open, setOpen] = useState(false); - const { tableRef, height } = useScrollToCurrentTime(); const handleMouseUpAddSchedule = () => { if (!time.selectedStartTime && !time.selectedEndTime) return; @@ -35,7 +35,7 @@ const CalendarTable = ( type='add' />} - + { + const { selectedWeek } = useSharedCalendarContext(); const start = new Date(card.startDateTime); const end = new Date(card.endDateTime); - const dayDiff = end.getDay() - start.getDay() + 1; - const { x: sx } = calcPositionByDate(start); + + const sizePosition = calcSizeByDate({ start, end }, selectedWeek); + + if (!sizePosition) return null; + const { x, width } = sizePosition; return ( { startTime={start} status={card.isAdjustable ? 'adjustable' : 'fixed'} style={{ - width: `calc((100% - 72px - 1.25rem) / 7 * ${dayDiff})`, + width: `calc((100% - 72px - 1.25rem) / 7 * ${width})`, height: 57, position: 'absolute', - left: `calc(((100% - 72px - 1.25rem) / 7 * ${sx}) + 72px)`, + left: `calc(((100% - 72px - 1.25rem) / 7 * ${x}) + 72px)`, top: 136, zIndex: 2, }} diff --git a/frontend/src/features/my-calendar/ui/MyDatePicker/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.css.ts similarity index 76% rename from frontend/src/features/my-calendar/ui/MyDatePicker/index.css.ts rename to frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.css.ts index c2c92bed..9882e8eb 100644 --- a/frontend/src/features/my-calendar/ui/MyDatePicker/index.css.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const pickerStyle = style({ width: `calc(17.75rem - 2 * ${vars.spacing[500]})`, }); diff --git a/frontend/src/features/my-calendar/ui/MyDatePicker/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.tsx similarity index 65% rename from frontend/src/features/my-calendar/ui/MyDatePicker/index.tsx rename to frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.tsx index 6864ee82..d53b9fb8 100644 --- a/frontend/src/features/my-calendar/ui/MyDatePicker/index.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/MyDatePicker/index.tsx @@ -1,5 +1,4 @@ -import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; -import DatePicker from '@/components/DatePicker'; +import { DatePicker, useSharedCalendarContext } from '@endolphin/calendar'; import { pickerStyle } from './index.css'; diff --git a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx similarity index 73% rename from frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx rename to frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx index 22b3373b..2dfdebe7 100644 --- a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardItem.tsx @@ -1,13 +1,14 @@ +import { + formatMinutesToTimeDuration, + getDateRangeString, + getTimeRangeString, + parseTime, +} from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Divider, Flex, Icon, Text } from '@endolphin/ui'; import type { PropsWithChildren, RefObject } from 'react'; -import { Divider } from '@/components/Divider'; -import { Flex } from '@/components/Flex'; -import { CalendarCheck, Clock } from '@/components/Icon'; -import { Text } from '@/components/Text'; import type { OngoingSchedule } from '@/features/shared-schedule/model'; -import { vars } from '@/theme/index.css'; -import { getDateRangeString, getTimeRangeString, parseTime } from '@/utils/date'; -import { formatMinutesToTimeDuration } from '@/utils/date/format'; import { cardStyle, cardTextStyle } from './index.css'; @@ -51,15 +52,15 @@ export const OngoingCardItem = ({ width='100%' > - + {getDateRangeString(dateRangeStart, dateRangeEnd)} - + {getTimeRangeString(parseTime(timeRangeStart), parseTime(timeRangeEnd))} - + {formatMinutesToTimeDuration(duration)} diff --git a/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx new file mode 100644 index 00000000..2db94a48 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx @@ -0,0 +1,74 @@ +import { useSharedCalendarContext } from '@endolphin/calendar'; +import { useClickOutside } from '@endolphin/core/hooks'; +import { parseTime, setTimeOnly } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Text } from '@endolphin/ui'; + +import { useOngoingQuery } from '@/features/shared-schedule/api/queries'; +import type { OngoingSchedule } from '@/features/shared-schedule/model'; +import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; +import { useTableContext } from '@/pages/MyCalendarPage/TableContext'; + +import { emptyTextStyle } from './index.css'; +import { OngoingCardItem } from './OngoingCardItem'; + +const formatDateTimeRange = ( + { dateRangeStart, dateRangeEnd, timeRangeStart, timeRangeEnd }: OngoingSchedule, +) => { + const start = new Date(dateRangeStart); + const end = new Date(dateRangeEnd); + const { hour: sh, minute: sm } = parseTime(timeRangeStart); + const { hour: eh, minute: em } = parseTime(timeRangeEnd); + + return { start, end, sh, sm, eh, em }; +}; + +const EmptyCard = () => ( + + 아직 조율 중인 일정이 없어요. + +); + +export const OngoingCardList = () => { + const { data, isPending } = useOngoingQuery(1, 2, 'ALL'); + const { selectedWeek } = useSharedCalendarContext(); + const { selectedId, setSelectedId, handleSelectDateRange, reset } = useDiscussionContext(); + const { handleSelectDate } = useSharedCalendarContext(); + const { handleSelectTime } = useTableContext(); + + const handleClickSelect = (discussion: OngoingSchedule | null) => { + if (!discussion) { + setSelectedId?.(null); + reset(); + return; + } + const { start, end, sh, sm, eh, em } = formatDateTimeRange(discussion); + setSelectedId?.(discussion.discussionId); + handleSelectDateRange( + setTimeOnly(start, { hour: sh, minute: sm, second: 0 }), + setTimeOnly(end, { hour: eh, minute: em, second: 0 }), + ); + handleSelectTime({ hour: sh, minute: sm, second: 0 }); + + if (selectedWeek[6] < start || selectedWeek[0] > end) handleSelectDate(start); + }; + + const cardRef = useClickOutside(()=>handleClickSelect(null)); + + if (isPending || !data) return null; + if (!data.ongoingDiscussions.length) return ; + + return data.ongoingDiscussions.map((discussion) => ( + handleClickSelect(discussion)} + ref={cardRef} + {...discussion} + /> + )); +}; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts similarity index 90% rename from frontend/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts rename to frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts index 4ef284fd..ac993adf 100644 --- a/frontend/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const titleContainerStyle = style({ padding: `${vars.spacing[300]} ${vars.spacing[300]} ${vars.spacing[300]} ${vars.spacing[400]}`, @@ -44,4 +43,8 @@ export const cardTextStyle = style({ display: 'flex', alignItems: 'center', gap: vars.spacing[200], +}); + +export const emptyTextStyle = style({ + padding: `0 ${vars.spacing[400]}`, }); \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/OngoingDiscussion/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.tsx similarity index 71% rename from frontend/src/features/my-calendar/ui/OngoingDiscussion/index.tsx rename to frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.tsx index 28deb88e..4986e50f 100644 --- a/frontend/src/features/my-calendar/ui/OngoingDiscussion/index.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/OngoingDiscussion/index.tsx @@ -1,7 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { Progress } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; import { titleContainerStyle } from './index.css'; import { OngoingCardList } from './OngoingCardList'; @@ -19,7 +17,7 @@ export const OngoingDiscussion = () => ( justify='flex-start' width='100%' > - + 조율 중인 일정 diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/AdjustableCheckbox.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/AdjustableCheckbox.tsx new file mode 100644 index 00000000..bc665df0 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/AdjustableCheckbox.tsx @@ -0,0 +1,20 @@ +import { Checkbox } from '@endolphin/ui'; + +import { usePopoverFormContext } from './PopoverContext'; + +export const AdjustableCheckbox = () => { + const { valuesRef, handleChange } = usePopoverFormContext(); + + return ( + handleChange({ name: 'isAdjustable', value: e.target.checked }), + }} + size='sm' + > + 시간 조정 가능 + + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/DateInput.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/DateInput.tsx new file mode 100644 index 00000000..6240c30b --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/DateInput.tsx @@ -0,0 +1,46 @@ +import { DatePicker, useMonthNavigation } from '@endolphin/calendar'; +import { formatDateToBarString } from '@endolphin/core/utils'; +import { Input } from '@endolphin/ui'; +import { useState } from 'react'; + +import { usePopoverFormContext } from './PopoverContext'; + +interface DateRange { + startDate: Date | null; + endDate: Date | null; +} + +export const DateInput = () => { + const { valuesRef, handleChange } = usePopoverFormContext(); + const startDate = new Date(valuesRef.current.startDate); + const endDate = new Date(valuesRef.current.endDate); + const [range, setRange] = useState({ startDate, endDate }); + + const handleClickStartDate = (date: Date | null) => { + handleChange({ name: 'startDate', value: formatDateToBarString(date) }); + setRange((prev) => ({ ...prev, startDate: date })); + }; + const handleClickEndDate = (date: Date | null) => { + handleChange({ name: 'endDate', value: formatDateToBarString(date) }); + setRange((prev) => ({ ...prev, endDate: date })); + }; + + return ( + + + + + } + /> + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/GoggleCalendarToggle.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/GoggleCalendarToggle.tsx new file mode 100644 index 00000000..4909780c --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/GoggleCalendarToggle.tsx @@ -0,0 +1,17 @@ +import { Toggle } from '@endolphin/ui'; + +import { usePopoverFormContext } from './PopoverContext'; + +export const GoogleCalendarToggle = () => { + const { valuesRef, handleChange } = usePopoverFormContext(); + + return ( + handleChange({ name: 'syncWithGoogleCalendar', value: e.target.checked }), + }} + /> + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx new file mode 100644 index 00000000..8487d926 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx @@ -0,0 +1,47 @@ +import { Button, Flex } from '@endolphin/ui'; + +import { useSchedulePopover } from '../../api/hooks'; +import { buttonStyle } from './index.css'; +import { usePopoverFormContext } from './PopoverContext'; +import type { SchedulePopoverBaseProps } from './type'; + +export const PopoverButton = ({ type, setIsOpen, reset, scheduleId }: SchedulePopoverBaseProps)=> { + const { valuesRef, handleSubmit } = usePopoverFormContext(); + const { handleClickCreate, handleClickEdit, handleClickDelete } = useSchedulePopover({ + setIsOpen, + reset, + scheduleId, + valuesRef, + }); + + return( + type === 'add' ? + : ( + + + + + ) + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverContext.ts b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverContext.ts new file mode 100644 index 00000000..c22d0354 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverContext.ts @@ -0,0 +1,16 @@ +import { useSafeContext } from '@endolphin/core/hooks'; +import type { FormRef } from '@hooks/useFormRef'; +import { createContext } from 'react'; + +import type { PersonalEventRequest } from '../../model'; + +export interface PersonalEventWithDateAndTime + extends Omit { + startDate: Date; + endDate: Date; + startTime: string; + endTime: string; +} + +export const PopoverFormContext = createContext | null>(null); +export const usePopoverFormContext = () => useSafeContext(PopoverFormContext); \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx new file mode 100644 index 00000000..9feefaad --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx @@ -0,0 +1,38 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; + +import { AdjustableCheckbox } from './AdjustableCheckbox'; +import { DateInput } from './DateInput'; +import { GoogleCalendarToggle } from './GoggleCalendarToggle'; +import { cardStyle, inputStyle } from './index.css'; +import { usePopoverFormContext } from './PopoverContext'; +import { TimeInput } from './TimeInput'; + +export const PopoverForm = () => { + const { valuesRef, handleChange } = usePopoverFormContext(); + return ( + <> + + handleChange({ name: 'title', value: e.target.value })} + placeholder='새 일정' + /> + + + + + + 구글 캘린더 연동 + + + + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/TimeInput.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/TimeInput.tsx new file mode 100644 index 00000000..ec60c71a --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/TimeInput.tsx @@ -0,0 +1,43 @@ +import { formatTimeStringToNumber, isSameDate } from '@endolphin/core/utils'; +import { Input } from '@endolphin/ui'; + +import { usePopoverFormContext } from './PopoverContext'; + +export const TimeInput = () => { + const { valuesRef, setValidation, errors, isValid, handleChange } = usePopoverFormContext(); + + setValidation('startTime', () => { + const startTime = formatTimeStringToNumber(valuesRef.current.startTime); + const endTime = formatTimeStringToNumber(valuesRef.current.endTime); + if (isSameDate(new Date(valuesRef.current.startDate), new Date(valuesRef.current.endDate)) + && startTime >= endTime) return '시작 시간은 종료 시간보다 빨라야 합니다.'; + return null; + }); + + return ( + + handleChange({ + name: 'startTime', + value: e.target.value, + })} + /> + handleChange({ + name: 'endTime', + value: e.target.value, + })} + /> + + ); +}; + \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/SchedulePopover/Title.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/Title.tsx similarity index 69% rename from frontend/src/features/my-calendar/ui/SchedulePopover/Title.tsx rename to frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/Title.tsx index 37f1cc09..99c769dc 100644 --- a/frontend/src/features/my-calendar/ui/SchedulePopover/Title.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/Title.tsx @@ -1,9 +1,7 @@ +import { vars } from '@endolphin/theme'; +import { Icon, Text } from '@endolphin/ui'; import type { ReactNode } from '@tanstack/react-router'; -import { Pencil, Plus } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; - import type { PopoverType } from '../../model'; import { titleStyle } from './index.css'; @@ -14,11 +12,11 @@ export const Title = ({ type }: { type: PopoverType })=>{ }> = { add: { title: '일정 추가', - icon: , + icon: , }, edit: { title: '일정 수정', - icon: , + icon: , }, }; diff --git a/frontend/src/features/my-calendar/ui/SchedulePopover/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.css.ts similarity index 93% rename from frontend/src/features/my-calendar/ui/SchedulePopover/index.css.ts rename to frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.css.ts index 560e7745..d9856438 100644 --- a/frontend/src/features/my-calendar/ui/SchedulePopover/index.css.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.css.ts @@ -1,10 +1,8 @@ +import { font, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { font } from '@/theme/font'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ - width: 262, + width: 360, padding: vars.spacing[300], diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.tsx new file mode 100644 index 00000000..42100d70 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/index.tsx @@ -0,0 +1,72 @@ +import { + calcPositionByDate, + formatDateToBarString, + formatDateToTimeString, +} from '@endolphin/core/utils'; +import { Flex } from '@endolphin/ui'; +import { useFormRef } from '@hooks/useFormRef'; + +import { backgroundStyle, containerStyle } from './index.css'; +import { PopoverButton } from './PopoverButton'; +import type { PersonalEventWithDateAndTime } from './PopoverContext'; +import { PopoverFormContext } from './PopoverContext'; +import { PopoverForm } from './PopoverForm'; +import { Title } from './Title'; +import type { DefaultEvent, SchedulePopoverProps } from './type'; + +const initEvent = (values?: DefaultEvent): DefaultEvent => { + if (values) return values; + return { + title: '제목 없음', + isAdjustable: false, + syncWithGoogleCalendar: true, + }; +}; + +const Background = ({ setIsOpen, reset }: Pick) => ( + { + setIsOpen(false); + reset?.(); + }} + /> +); + +export const SchedulePopover = ( + { setIsOpen, reset, scheduleId, type, values, ...event }: SchedulePopoverProps, +) => { + const startDate = new Date(event.startDateTime); + const endDate = new Date(event.endDateTime); + const { x: sx } = calcPositionByDate(startDate); + + return( + ({ + startTime: formatDateToTimeString(startDate), + endTime: formatDateToTimeString(endDate), + startDate: formatDateToBarString(startDate), + endDate: formatDateToBarString(endDate), + ...initEvent(values), + })} + > + + + <PopoverForm /> + <PopoverButton + reset={reset} + scheduleId={scheduleId} + setIsOpen={setIsOpen} + type={type} + /> + </dialog> + <Background reset={reset} setIsOpen={setIsOpen} /> + </PopoverFormContext.Provider> + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/type.ts b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/type.ts new file mode 100644 index 00000000..263fecd0 --- /dev/null +++ b/frontend/apps/client/src/features/my-calendar/ui/SchedulePopover/type.ts @@ -0,0 +1,14 @@ +import type { PersonalEventRequest, PopoverType } from '../../model'; + +export type DefaultEvent = Omit<PersonalEventRequest, 'endDateTime' | 'startDateTime'>; + +export interface SchedulePopoverBaseProps { + scheduleId?: number; + values?: DefaultEvent; + setIsOpen: (isOpen: boolean) => void; + type: PopoverType; + reset?: () => void; +} + +export type SchedulePopoverProps + = SchedulePopoverBaseProps & Pick<PersonalEventRequest, 'endDateTime' | 'startDateTime'>; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/SideBar/index.css.ts b/frontend/apps/client/src/features/my-calendar/ui/SideBar/index.css.ts similarity index 88% rename from frontend/src/features/my-calendar/ui/SideBar/index.css.ts rename to frontend/apps/client/src/features/my-calendar/ui/SideBar/index.css.ts index 1254c0d4..4a6471bb 100644 --- a/frontend/src/features/my-calendar/ui/SideBar/index.css.ts +++ b/frontend/apps/client/src/features/my-calendar/ui/SideBar/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const sideBarStyle = style({ minWidth: '17.75rem', height: 'calc(100vh - 150px)', diff --git a/frontend/src/features/my-calendar/ui/SideBar/index.tsx b/frontend/apps/client/src/features/my-calendar/ui/SideBar/index.tsx similarity index 77% rename from frontend/src/features/my-calendar/ui/SideBar/index.tsx rename to frontend/apps/client/src/features/my-calendar/ui/SideBar/index.tsx index 11ace56b..98fcc7a4 100644 --- a/frontend/src/features/my-calendar/ui/SideBar/index.tsx +++ b/frontend/apps/client/src/features/my-calendar/ui/SideBar/index.tsx @@ -1,6 +1,6 @@ -import { Divider } from '@/components/Divider'; -import { Flex } from '@/components/Flex'; +import { Divider, Flex } from '@endolphin/ui'; +import LinkedCalendar from '../LinkedCalendar'; import { MyDatePicker } from '../MyDatePicker'; import { OngoingDiscussion } from '../OngoingDiscussion'; import { sideBarStyle } from './index.css'; @@ -16,6 +16,7 @@ const SideBar = () => ( <MyDatePicker /> <Divider /> <OngoingDiscussion /> + <LinkedCalendar /> </Flex> ); diff --git a/frontend/src/features/shared-schedule/api/index.ts b/frontend/apps/client/src/features/shared-schedule/api/index.ts similarity index 76% rename from frontend/src/features/shared-schedule/api/index.ts rename to frontend/apps/client/src/features/shared-schedule/api/index.ts index fa3ab587..ccc6d6d1 100644 --- a/frontend/src/features/shared-schedule/api/index.ts +++ b/frontend/apps/client/src/features/shared-schedule/api/index.ts @@ -1,14 +1,16 @@ -import { MINUTE_IN_MILLISECONDS } from '@/utils/date'; -import { request } from '@/utils/fetch'; +import { MINUTE_IN_MILLISECONDS } from '@endolphin/core/utils'; +import { request } from '@utils/fetch'; import type { AttendType, OngoingSchedulesResponse, + UpcomingScheduleDetailsResponse, UpcomingSchedulesResponse, } from '../model'; import { FinishedSchedulesResponseSchema, OngoingSchedulesResponseSchema, + UpcomingScheduleDetailsResponseSchema, UpcomingSchedulesResponseSchema, } from '../model'; import type { FinishedSchedulesResponse } from '../model/finishedSchedules'; @@ -22,6 +24,13 @@ export const schedulesApi = { const parsedData = UpcomingSchedulesResponseSchema.parse(response); return parsedData; }, + getUpcomingScheduleDetail: async ( + discussionId: string, + ): Promise<UpcomingScheduleDetailsResponse> => { + const response = await request.get(ENDPOINT_PREFIX + '/upcoming/' + discussionId); + const parsedData = UpcomingScheduleDetailsResponseSchema.parse(response); + return parsedData; + }, getOngoingSchedules: async ( page: number, size: number, diff --git a/frontend/src/features/shared-schedule/api/keys.ts b/frontend/apps/client/src/features/shared-schedule/api/keys.ts similarity index 51% rename from frontend/src/features/shared-schedule/api/keys.ts rename to frontend/apps/client/src/features/shared-schedule/api/keys.ts index 0b0e7c71..4dc047f1 100644 --- a/frontend/src/features/shared-schedule/api/keys.ts +++ b/frontend/apps/client/src/features/shared-schedule/api/keys.ts @@ -1,15 +1,19 @@ import type { AttendType } from '../model'; -export const upcomingQueryKey = ['upcoming']; +export const sharedScheduleKey = ['shared-schedule']; + +export const upcomingQueryKey = [...sharedScheduleKey, 'upcoming']; + +export const upcomingDetailsQueryKey = (discussionId: string) => ['upcomingDetails', discussionId]; export const ongoingQueryKey = { - all: ['ongoing'], + all: [...sharedScheduleKey, 'ongoing'], detail: (page: number, size: number, type: AttendType) => [...ongoingQueryKey.all, page, size, type], }; export const finishedQueryKey = { - all: ['finished'], + all: [...sharedScheduleKey, 'finished'], detail: (page: number, size: number, year: number) => [...finishedQueryKey.all, page, size, year], -}; +}; \ No newline at end of file diff --git a/frontend/src/features/shared-schedule/api/prefetch.ts b/frontend/apps/client/src/features/shared-schedule/api/prefetch.ts similarity index 78% rename from frontend/src/features/shared-schedule/api/prefetch.ts rename to frontend/apps/client/src/features/shared-schedule/api/prefetch.ts index a1f0f201..0621afff 100644 --- a/frontend/src/features/shared-schedule/api/prefetch.ts +++ b/frontend/apps/client/src/features/shared-schedule/api/prefetch.ts @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; -import type { AttendType } from '../model'; +import { type AttendType, FINISHED_SCHEDULE_FETCH_SIZE } from '../model'; import { sharedSchedulesQueryOptions } from './queryOptions'; export const prefetchUpcomingSchedules = async (queryClient: QueryClient) => { @@ -18,6 +18,6 @@ export const prefetchOngoingSchedules = async ( export const prefetchFinishedSchedules = async (queryClient: QueryClient) => { await queryClient.prefetchQuery( - sharedSchedulesQueryOptions.finished(1, 10, new Date().getFullYear()), + sharedSchedulesQueryOptions.finished(1, FINISHED_SCHEDULE_FETCH_SIZE, new Date().getFullYear()), ); }; diff --git a/frontend/src/features/shared-schedule/api/queries.ts b/frontend/apps/client/src/features/shared-schedule/api/queries.ts similarity index 69% rename from frontend/src/features/shared-schedule/api/queries.ts rename to frontend/apps/client/src/features/shared-schedule/api/queries.ts index 811b6596..851d5d1d 100644 --- a/frontend/src/features/shared-schedule/api/queries.ts +++ b/frontend/apps/client/src/features/shared-schedule/api/queries.ts @@ -1,6 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import type { FinishedSchedulesResponse, UpcomingSchedulesResponse } from '../model'; +import type { + FinishedSchedulesResponse, + UpcomingScheduleDetailsResponse, + UpcomingSchedulesResponse, +} from '../model'; import type { AttendType, OngoingSchedulesResponse } from '../model/ongoingSchedules'; import { sharedSchedulesQueryOptions } from './queryOptions'; @@ -8,6 +12,12 @@ export const useUpcomingQuery = () => useQuery<UpcomingSchedulesResponse>( sharedSchedulesQueryOptions.upcoming, ); +export const useUpcomingDetailsQuery = (discussionId: string) => ( + useQuery<UpcomingScheduleDetailsResponse>( + sharedSchedulesQueryOptions.upcomingDetails(discussionId), + ) +); + export const useOngoingQuery = (page: number, size: number, attendType: AttendType) => useQuery<OngoingSchedulesResponse>( sharedSchedulesQueryOptions.ongoing(page, size, attendType), diff --git a/frontend/src/features/shared-schedule/api/queryOptions.ts b/frontend/apps/client/src/features/shared-schedule/api/queryOptions.ts similarity index 71% rename from frontend/src/features/shared-schedule/api/queryOptions.ts rename to frontend/apps/client/src/features/shared-schedule/api/queryOptions.ts index 1076683f..2f7e1df3 100644 --- a/frontend/src/features/shared-schedule/api/queryOptions.ts +++ b/frontend/apps/client/src/features/shared-schedule/api/queryOptions.ts @@ -3,13 +3,22 @@ import { keepPreviousData } from '@tanstack/react-query'; import type { AttendType } from '../model'; import { schedulesApi } from '.'; -import { finishedQueryKey, ongoingQueryKey, upcomingQueryKey } from './keys'; +import { + finishedQueryKey, + ongoingQueryKey, + upcomingDetailsQueryKey, + upcomingQueryKey, +} from './keys'; export const sharedSchedulesQueryOptions = { upcoming: { queryKey: upcomingQueryKey, queryFn: () => schedulesApi.getUpcomingSchedules(), }, + upcomingDetails: (discussionId: string) => ({ + queryKey: upcomingDetailsQueryKey(discussionId), + queryFn: () => schedulesApi.getUpcomingScheduleDetail(discussionId), + }), ongoing: (page: number, size: number, attendtype: AttendType) => ({ queryKey: ongoingQueryKey.detail(page, size, attendtype), queryFn: () => schedulesApi.getOngoingSchedules(page, size, attendtype), diff --git a/frontend/apps/client/src/features/shared-schedule/model/SharedEventDto.ts b/frontend/apps/client/src/features/shared-schedule/model/SharedEventDto.ts new file mode 100644 index 00000000..37dec9e4 --- /dev/null +++ b/frontend/apps/client/src/features/shared-schedule/model/SharedEventDto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const SharedEventDtoSchema = z.object({ + id: z.number(), + startDateTime: z.string(), + endDateTime: z.string(), +}); + +export type SharedEventDto = z.infer<typeof SharedEventDtoSchema>; diff --git a/frontend/src/features/shared-schedule/model/finishedSchedules.ts b/frontend/apps/client/src/features/shared-schedule/model/finishedSchedules.ts similarity index 67% rename from frontend/src/features/shared-schedule/model/finishedSchedules.ts rename to frontend/apps/client/src/features/shared-schedule/model/finishedSchedules.ts index 1e398b8f..dcf8eea9 100644 --- a/frontend/src/features/shared-schedule/model/finishedSchedules.ts +++ b/frontend/apps/client/src/features/shared-schedule/model/finishedSchedules.ts @@ -1,21 +1,12 @@ import { z } from 'zod'; -import { zCoerceToDate } from '@/utils/zod'; - -const SharedEventDtoSchema = z.union([ - z.object({ - id: z.number(), - startDateTime: zCoerceToDate, - endDateTime: zCoerceToDate, - }), - z.null(), -]); +import { SharedEventDtoSchema } from './SharedEventDto'; const FinishedScheduleSchema = z.object({ discussionId: z.number(), title: z.string(), meetingMethodOrLocation: z.union([z.string(), z.null()]), - sharedEventDto: SharedEventDtoSchema, + sharedEventDto: z.union([SharedEventDtoSchema, z.null()]), participantPictureUrls: z.array(z.string()), }); @@ -30,4 +21,3 @@ export const FinishedSchedulesResponseSchema = z.object({ export type FinishedSchedulesResponse = z.infer<typeof FinishedSchedulesResponseSchema>; export type FinishedSchedule = z.infer<typeof FinishedScheduleSchema>; -export type SharedEventDto = z.infer<typeof SharedEventDtoSchema>; diff --git a/frontend/src/features/shared-schedule/model/index.ts b/frontend/apps/client/src/features/shared-schedule/model/index.ts similarity index 53% rename from frontend/src/features/shared-schedule/model/index.ts rename to frontend/apps/client/src/features/shared-schedule/model/index.ts index fa249557..bac39b46 100644 --- a/frontend/src/features/shared-schedule/model/index.ts +++ b/frontend/apps/client/src/features/shared-schedule/model/index.ts @@ -1,3 +1,6 @@ export * from './finishedSchedules'; export * from './ongoingSchedules'; export * from './upcomingSchedules'; + +export const ONGOING_SCHEDULE_FETCH_SIZE = 6; +export const FINISHED_SCHEDULE_FETCH_SIZE = 7; diff --git a/frontend/src/features/shared-schedule/model/ongoingSchedules.ts b/frontend/apps/client/src/features/shared-schedule/model/ongoingSchedules.ts similarity index 94% rename from frontend/src/features/shared-schedule/model/ongoingSchedules.ts rename to frontend/apps/client/src/features/shared-schedule/model/ongoingSchedules.ts index 290ce324..8d2f33af 100644 --- a/frontend/src/features/shared-schedule/model/ongoingSchedules.ts +++ b/frontend/apps/client/src/features/shared-schedule/model/ongoingSchedules.ts @@ -1,7 +1,6 @@ +import { zCoerceToDate } from '@utils/zod'; import { z } from 'zod'; -import { zCoerceToDate } from '@/utils/zod'; - const OngoingScheduleSchema = z.object({ discussionId: z.number(), title: z.string(), diff --git a/frontend/apps/client/src/features/shared-schedule/model/upcomingSchedules.ts b/frontend/apps/client/src/features/shared-schedule/model/upcomingSchedules.ts new file mode 100644 index 00000000..b46f9c2d --- /dev/null +++ b/frontend/apps/client/src/features/shared-schedule/model/upcomingSchedules.ts @@ -0,0 +1,32 @@ +import { zCoerceToDate } from '@utils/zod'; +import { z } from 'zod'; + +import { ParticipantWithEventsSchema } from '@/features/timeline-schedule/model'; + +import { SharedEventDtoSchema } from './SharedEventDto'; + +export const UpcomingScheduleSchema = z.object({ + discussionId: z.number(), + title: z.string(), + meetingMethodOrLocation: z.union([z.string(), z.null()]), + sharedEventDto: SharedEventDtoSchema, + participantPictureUrls: z.array(z.string()), +}); + +export const UpcomingSchedulesResponseSchema = z.object({ + data: z.array(UpcomingScheduleSchema), +}); + +export const UpcomingScheduleDetailsResponseSchema = z.object({ + // TODO: discussionId 인코딩 적용 후 z.string()으로 변경 + discussionId: z.union([z.string(), z.number()]), + title: z.string(), + startDateTime: zCoerceToDate, + endDateTime: zCoerceToDate, + deadline: zCoerceToDate, + participants: z.array(ParticipantWithEventsSchema), +}); + +export type UpcomingSchedule = z.infer<typeof UpcomingScheduleSchema>; +export type UpcomingSchedulesResponse = z.infer<typeof UpcomingSchedulesResponseSchema>; +export type UpcomingScheduleDetailsResponse = z.infer<typeof UpcomingScheduleDetailsResponseSchema>; diff --git a/frontend/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx similarity index 53% rename from frontend/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx index 27928e73..31e92691 100644 --- a/frontend/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/FinishedFallback.tsx @@ -1,7 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { ClockGraphic } from '@/components/Icon/custom/ClockGraphic'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; const FinishedFallback = () => ( <Flex @@ -11,7 +9,7 @@ const FinishedFallback = () => ( height='41.375rem' width='full' > - <ClockGraphic height={200} width={200} /> + <Icon.ClockGraphic height={200} width={200} /> <Text color={vars.color.Ref.Netural[700]} typo='h3'>지난 일정이 없어요</Text> </Flex> ); diff --git a/frontend/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx similarity index 62% rename from frontend/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx index bbfdbac7..33ef7c3d 100644 --- a/frontend/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/OngoingFallback.tsx @@ -1,7 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { ClockGraphic } from '@/components/Icon/custom/ClockGraphic'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; import { ongoingFallbackContainerStyle } from './index.css'; @@ -14,7 +12,7 @@ const OngoingFallback = () => ( height='35.25rem' width='full' > - <ClockGraphic height={200} width={200} /> + <Icon.ClockGraphic height={200} width={200} /> <Text color={vars.color.Ref.Netural[700]} typo='h3'>확정되지 않은 일정이 없어요</Text> </Flex> ); diff --git a/frontend/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx similarity index 62% rename from frontend/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx index e6bdcd88..86fe8687 100644 --- a/frontend/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/UpcomingFallback.tsx @@ -1,7 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { CheckGraphic } from '@/components/Icon/custom/CheckGraphic'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; import { ongoingFallbackContainerStyle } from './index.css'; @@ -14,7 +12,7 @@ const UpcomingFallback = () => ( height='19.75rem' width='full' > - <CheckGraphic height={180} width={180} /> + <Icon.CheckGraphic height={180} width={180} /> <Text color={vars.color.Ref.Netural[700]} typo='h3'>아직 다가오는 일정이 없어요!</Text> </Flex> ); diff --git a/frontend/src/features/shared-schedule/ui/Fallbacks/index.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/index.css.ts similarity index 81% rename from frontend/src/features/shared-schedule/ui/Fallbacks/index.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/index.css.ts index 77899560..ea859209 100644 --- a/frontend/src/features/shared-schedule/ui/Fallbacks/index.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/Fallbacks/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const ongoingFallbackContainerStyle = style({ backgroundColor: vars.color.Ref.CoolGrey[50], borderRadius: vars.radius[700], diff --git a/frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx similarity index 90% rename from frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx index 4d565774..24bbacea 100644 --- a/frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleList.tsx @@ -1,23 +1,20 @@ +import { Flex, Pagination } from '@endolphin/ui'; import { useState } from 'react'; -import { Flex } from '@/components/Flex'; -import Pagination from '@/components/Pagination'; - import { useFinishedQuery } from '../../api/queries'; +import { FINISHED_SCHEDULE_FETCH_SIZE } from '../../model'; import FinishedFallback from '../Fallbacks/FinishedFallback'; import { paginationStyle, scheduleListStyle } from './finishedScheduleList.css'; import FinishedScheduleListItem from './FinishedScheduleListItem'; -export const FINISHED_PAGE_SIZE = 7; - interface FinishedScheduleListProps { baseYear: number; } const FinishedScheduleList = ({ baseYear }: FinishedScheduleListProps) => { const [currentPage, setCurrentPage] = useState(1); - const { data, isPending } = useFinishedQuery(currentPage, FINISHED_PAGE_SIZE, baseYear); + const { data, isPending } = useFinishedQuery(currentPage, FINISHED_SCHEDULE_FETCH_SIZE, baseYear); if (isPending || !data) return <div className={scheduleListStyle} />; if (data.finishedDiscussions.length === 0) return <FinishedFallback />; diff --git a/frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx similarity index 81% rename from frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx index 4310e5f5..2dee2899 100644 --- a/frontend/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/FinishedScheduleListItem.tsx @@ -1,10 +1,8 @@ -import Avatar from '@/components/Avatar'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { getDateTimeRangeString } from '@/utils/date'; +import { getDateTimeRangeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Avatar, Flex, Text } from '@endolphin/ui'; -import type { SharedEventDto } from '../../model'; +import type { SharedEventDto } from '../../model/SharedEventDto'; import { dotStyle, scheduleItemContainerStyle, @@ -14,7 +12,7 @@ interface FinishedScheduleListItemProps { scheduleTitle: string; participantImageUrls: string[]; meetingPlace?: string | null; - sharedEventDto: SharedEventDto; + sharedEventDto: SharedEventDto | null; // startDate: Date; // endDate: Date; onClick?: () => void; @@ -53,11 +51,11 @@ const FinishedScheduleListItem = ({ </Flex> ); -const MeetDate = ({ sharedEventDto }: { sharedEventDto: SharedEventDto }) => ( +const MeetDate = ({ sharedEventDto }: { sharedEventDto: SharedEventDto | null }) => ( <Text color={vars.color.Ref.Netural[600]} typo='b3R'> {sharedEventDto ? getDateTimeRangeString( - sharedEventDto.startDateTime, - sharedEventDto.endDateTime) + new Date(sharedEventDto.startDateTime), + new Date(sharedEventDto.endDateTime)) : '조율되지 않은 일정이에요'} </Text> ); diff --git a/frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts similarity index 89% rename from frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts index 2c795202..4d976a0e 100644 --- a/frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleList.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const scheduleListStyle = style({ flexDirection: 'column', gap: vars.spacing[600], diff --git a/frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts similarity index 89% rename from frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts index 42830493..65eefac0 100644 --- a/frontend/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/finishedScheduleListItem.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const scheduleItemContainerStyle = style({ width: '100%', padding: `${vars.spacing[400]} ${vars.spacing[600]}`, diff --git a/frontend/src/features/shared-schedule/ui/FinishedSchedules/index.tsx b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/index.tsx similarity index 81% rename from frontend/src/features/shared-schedule/ui/FinishedSchedules/index.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/index.tsx index 1ac642cf..58c6f900 100644 --- a/frontend/src/features/shared-schedule/ui/FinishedSchedules/index.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/FinishedSchedules/index.tsx @@ -1,10 +1,7 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; import { useState } from 'react'; -import { Flex } from '@/components/Flex'; -import { ChevronLeft, ChevronRight } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; - import FinishedScheduleList from './FinishedScheduleList'; const FinishedSchedules = () => { @@ -20,7 +17,7 @@ const FinishedSchedules = () => { <Text typo='h2'>지난 일정</Text> <Flex gap={200}> {/* TODO: 연도 validation */} - <ChevronLeft + <Icon.ChevronLeft clickable={true} fill={vars.color.Ref.Netural[400]} onClick={() => setCurrentYear(currentYear - 1)} @@ -28,7 +25,7 @@ const FinishedSchedules = () => { <Text color={vars.color.Ref.Netural[700]} typo='b2M'> {`${currentYear}년`} </Text> - <ChevronRight + <Icon.ChevronRight clickable={true} fill={vars.color.Ref.Netural[700]} onClick={() => setCurrentYear(currentYear + 1)} diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx similarity index 77% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx index 9e7aac6b..19b592d7 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleList.tsx @@ -1,12 +1,8 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Pagination, Text } from '@endolphin/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; -import { Flex } from '@/components/Flex'; -import Pagination from '@/components/Pagination'; -import { Text } from '@/components/Text'; -import { usePagination } from '@/hooks/usePagination'; -import { vars } from '@/theme/index.css'; - import { prefetchOngoingSchedules } from '../../api/prefetch'; import { useOngoingQuery } from '../../api/queries'; import type { OngoingSegmentOption } from '.'; @@ -37,7 +33,7 @@ interface OngoingScheduleListProps { // TODO: useEffect 뺄 수 있으면 다른 걸로 대체 const OngoingScheduleList = ({ segmentOption }: OngoingScheduleListProps) => { const queryClient = useQueryClient(); - const { currentPage, handlePageChange } = usePagination(1); + const [currentPage, handlePageChange] = useState(1); const [selectedIndex, setSelectedIndex] = useState(0); const { data, isPending } = useOngoingQuery(currentPage, PAGE_SIZE, segmentOption.value ); if (isPending) return <div>pending...</div>; @@ -57,19 +53,17 @@ const OngoingScheduleList = ({ segmentOption }: OngoingScheduleListProps) => { selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} /> - {data.totalPages > 1 && ( - <Pagination - className={paginationStyle} - currentPage={currentPage} - onPageButtonHover={(page) => - prefetchOngoingSchedules(queryClient, page, PAGE_SIZE, segmentOption.value )} - onPageChange={(page: number) => { - setSelectedIndex(0); - handlePageChange(page); - }} - totalPages={data.totalPages} - /> - )} + <Pagination + className={paginationStyle} + currentPage={currentPage} + onPageButtonHover={(page) => + prefetchOngoingSchedules(queryClient, page, PAGE_SIZE, segmentOption.value )} + onPageChange={(page: number) => { + setSelectedIndex(0); + handlePageChange(page); + }} + totalPages={data.totalPages} + /> </Flex> <ScheduleDetails discussionId={data.ongoingDiscussions[selectedIndex].discussionId} /> </div> diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx similarity index 84% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx index 7d629b63..aae56c33 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/OngoingScheduleListItem.tsx @@ -1,10 +1,11 @@ -import Avatar from '@/components/Avatar'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { getDateRangeString, getTimeLeftInfoFromMilliseconds } from '@/utils/date'; -import { formatTimeToDeadlineString } from '@/utils/date/format'; +import { + formatTimeToDeadlineString, + getDateRangeString, + getTimeLeftInfoFromMilliseconds, +} from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Avatar, Flex, Text } from '@endolphin/ui'; import type { OngoingSchedule } from '../../model'; import { diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx similarity index 61% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx index a7c133a1..5300e11b 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/RecommendedSchedules.tsx @@ -1,19 +1,18 @@ +import { getDowString, + getTimeDiffString, + getTimeParts, + getTimeRangeString, + getYearMonthDay, +} from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Chip, Flex, Text } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { - useDiscussionCalendarQuery, - useDiscussionParticipantsQuery, -} from '@/features/discussion/api/queries'; +import { useDiscussionCalendarQuery } from '@/features/discussion/api/queries'; import type { DiscussionDTO, DiscussionResponse, } from '@/features/discussion/model'; -import { vars } from '@/theme/index.css'; -import { getTimeDiffString, getTimeParts, getTimeRangeString, getYearMonthDay } from '@/utils/date'; -import { getDowString } from '@/utils/date/format'; import { ONGOING_SCHEDULE_DETAIL_GC_TIME } from '../../api'; import { @@ -22,17 +21,10 @@ import { recommendItemStyle, } from './recommendedSchedules.css'; -const RecommendedSchedules = ({ discussion }: { - discussion: DiscussionResponse; -}) => { - const { participants, isPending: isParticipantsLoading } = - useDiscussionParticipantsQuery(discussion.id.toString()); - const { calendar: candidates, isPending: isCandidateLoading } = useDiscussionCalendarQuery( +const RecommendedSchedules = ({ discussion }: { discussion: DiscussionResponse }) => { + const { calendar: candidates = [] } = useDiscussionCalendarQuery( discussion.id.toString(), { size: 3 }, ONGOING_SCHEDULE_DETAIL_GC_TIME, ); - - if (isCandidateLoading || isParticipantsLoading || !candidates || !participants) - return <div className={recommendContainerStyle} />; return ( <Flex @@ -42,21 +34,19 @@ const RecommendedSchedules = ({ discussion }: { width='full' > <Text typo='t2'>추천 일정</Text> - { - candidates.length === 0 ? - <NoRecommendationText /> - : - candidates.map((candidate, idx) => ( - <RecommendedScheduleItem - adjustCount={candidate.usersForAdjust.length} - candidate={candidate} - discussionId={discussion.id} - endDTStr={candidate.endDateTime} - key={`${JSON.stringify(candidate)}-${idx}`} - startDTStr={candidate.startDateTime} - /> - )) - } + {candidates.length === 0 ? + <NoRecommendationText /> + : + candidates.map((candidate, idx) => ( + <RecommendedScheduleItem + adjustCount={candidate.usersForAdjust.length} + candidate={candidate} + discussionId={discussion.id} + endDTStr={candidate.endDateTime} + key={`${JSON.stringify(candidate)}-${idx}`} + startDTStr={candidate.startDateTime} + /> + ))} </Flex> ); }; @@ -101,7 +91,7 @@ const AvailableChip = ({ adjustCount }: { adjustCount: number }) => ( color={adjustCount > 0 ? 'red' : 'blue'} radius='max' size='md' - style='weak' + variant='weak' > {adjustCount > 0 ? `조율 필요 ${adjustCount}` : '모두 가능'} </Chip> diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts similarity index 85% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts index d9a856d4..00a85b6c 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: 396, height: 605, diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx similarity index 88% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx index 4165fddf..54b6d285 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ScheduleDetails.tsx @@ -1,21 +1,17 @@ +import { + formatMinutesToTimeDuration, formatTimeToDeadlineString, getDateRangeString, + getTimeLeftInfoFromMilliseconds } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Button, Flex, Text } from '@endolphin/ui'; +import { useClipboard } from '@hooks/useClipboard'; import { Link } from '@tanstack/react-router'; -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; import { serviceENV } from '@/envconfig'; import { useDiscussionQuery, } from '@/features/discussion/api/queries'; import type { DiscussionResponse } from '@/features/discussion/model'; -import { useClipboard } from '@/hooks/useClipboard'; -import { vars } from '@/theme/index.css'; -import { - getDateRangeString, - getTimeLeftInfoFromMilliseconds, -} from '@/utils/date'; -import { formatMinutesToTimeDuration, formatTimeToDeadlineString } from '@/utils/date/format'; import RecommendedSchedules from './RecommendedSchedules'; import { diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts similarity index 91% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts index 3f15da2d..1dac88d1 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ paddingBottom: vars.spacing[700], }); diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/index.tsx b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.tsx similarity index 88% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/index.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.tsx index 81211a23..ac227071 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/index.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/index.tsx @@ -1,14 +1,11 @@ +import { Flex, SegmentControl, Text } from '@endolphin/ui'; import { useQueryClient } from '@tanstack/react-query'; -import { Flex } from '@/components/Flex'; -import SegmentControl from '@/components/SegmentControl'; -import { Text } from '@/components/Text'; - import { prefetchOngoingSchedules } from '../../api/prefetch'; import { useOngoingQuery } from '../../api/queries'; import { sharedSchedulesQueryOptions } from '../../api/queryOptions'; -import type { AttendType } from '../../model/'; +import { type AttendType, ONGOING_SCHEDULE_FETCH_SIZE } from '../../model/'; import { ongoingFallbackContainerStyle } from '../Fallbacks/index.css'; import OngoingFallback from '../Fallbacks/OngoingFallback'; import { containerStyle, segmentControlStyle, titleStyle } from './index.css'; @@ -42,7 +39,9 @@ const Content = () => { const { data, isPending } = useOngoingQuery(1, 6, 'ALL'); if (!data || isPending) return <div className={ongoingFallbackContainerStyle} />; if (data.totalPages === 0) { - queryClient.fetchQuery(sharedSchedulesQueryOptions.ongoing(1, 6, 'ALL')); + queryClient.fetchQuery( + sharedSchedulesQueryOptions.ongoing(1, ONGOING_SCHEDULE_FETCH_SIZE, 'ALL'), + ); return <OngoingFallback />; } diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts similarity index 84% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts index 822d0803..d1d6e3bc 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleList.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const scheduleListStyle = style({ padding: `0 ${vars.spacing[600]} ${vars.spacing[600]} 0`, }); diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts similarity index 96% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts index f5e781f7..e42e3192 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/ongoingScheduleListItem.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const scheduleItemContainerStyle = recipe({ base: { display: 'flex', diff --git a/frontend/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts similarity index 95% rename from frontend/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts index 640de6f2..54f2f3f1 100644 --- a/frontend/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/OngoingSchedules/recommendedSchedules.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: 396, height: 552, diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx similarity index 87% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx index 5de28dbd..df2dc2c1 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ControlButton.tsx @@ -1,6 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { ChevronLeft, ChevronRight } from '@/components/Icon'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon } from '@endolphin/ui'; import { controlButtonStyle } from './index.css'; @@ -41,7 +40,7 @@ export const LeftControlButton = ({ isAvailable, onClick }: ControlButtonProps) disabled={!isAvailable} onClick={onClick} > - <ChevronLeft + <Icon.ChevronLeft clickable={isAvailable} fill={vars.color.Ref.Netural[600]} /> @@ -54,7 +53,7 @@ export const RightControlButton = ({ isAvailable, onClick }: ControlButtonProps) disabled={!isAvailable} onClick={onClick} > - <ChevronRight + <Icon.ChevronRight clickable={isAvailable} fill={vars.color.Ref.Netural[600]} /> diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx similarity index 85% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx index c330574b..0453da5d 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/ScheduleCard.tsx @@ -1,14 +1,8 @@ +import { formatDateToDdayString, getDateTimeRangeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Avatar, Chip, Flex, Icon, Text } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Avatar from '@/components/Avatar'; -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { ChevronRight } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { getDateTimeRangeString } from '@/utils/date'; -import { formatDateToDdayString } from '@/utils/date/format'; - import type { UpcomingSchedule } from '../../model'; import { chevronButtonStyle, containerStyle } from './scheduleCard.css'; @@ -66,7 +60,7 @@ const DdayChip = ({ endDateTime, latest }: { color={latest ? 'black' : 'coolGray'} radius='max' size='md' - style={latest ? 'filled' : 'weak'} + variant={latest ? 'filled' : 'weak'} > {formatDateToDdayString(endDateTime)} </Chip> @@ -95,7 +89,7 @@ const MeetingDateTimeInfo = ({ startDateTime, endDateTime }: { ); const ChevronButton = ({ latest }: { latest: boolean }) => ( - <ChevronRight + <Icon.ChevronRight className={chevronButtonStyle({ latest })} clickable fill={vars.color.Ref.Netural[800]} diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts similarity index 94% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts index 524a3718..78cbe1f8 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const carouselStyle = style({ position: 'absolute', top: 202, diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.tsx similarity index 100% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel.tsx diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx similarity index 94% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx index 427640c4..97e49ef9 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleList.tsx @@ -1,8 +1,7 @@ +import { Flex } from '@endolphin/ui'; import type { PropsWithChildren } from 'react'; -import { Flex } from '@/components/Flex'; - import type { UpcomingSchedule } from '../../model'; import UpcomingScheduleListItem from './UpcomingScheduleListItem'; diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx similarity index 87% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx index ed65b661..4c85a2ce 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/UpcomingScheduleListItem.tsx @@ -1,13 +1,8 @@ +import { formatDateToDdayString, getDateTimeRangeString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Avatar, Chip, Flex, Text } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Avatar from '@/components/Avatar'; -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { getDateTimeRangeString } from '@/utils/date'; -import { formatDateToDdayString } from '@/utils/date/format'; - import type { UpcomingSchedule } from '../../model'; import { dotStyle, @@ -71,7 +66,7 @@ const Content = ({ <Chip color='black' radius='max' - style='weak' + variant='weak' > {formatDateToDdayString(startDate)} </Chip> diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts similarity index 91% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts index 39e60dac..4b6b2629 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const controlButtonStyle = recipe({ base: { backgroundColor: vars.color.Ref.Netural[200], diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx similarity index 90% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx index 40d3ec77..d3b7e744 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/index.tsx @@ -1,6 +1,6 @@ -import { Flex } from '@/components/Flex'; -import { useCarouselControl } from '@/hooks/useCarousel'; +import { Flex } from '@endolphin/ui'; +import { useCarouselControl } from '@hooks/useCarousel'; import type { UpcomingSchedule } from '../../model'; import ControlButtons from './ControlButton'; diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts similarity index 95% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts index 8cae1bb8..c4b3c437 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/scheduleCard.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = recipe({ base: { width: 358, diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts similarity index 77% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts index 13810b38..d5ae0389 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleList.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const scheduleListStyle = style({ padding: `0 ${vars.spacing[600]} ${vars.spacing[600]} 0`, }); diff --git a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts similarity index 91% rename from frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts rename to frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts index d48c763c..071beb4a 100644 --- a/frontend/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts +++ b/frontend/apps/client/src/features/shared-schedule/ui/UpcomingSchedules/upcomingScheduleListItem.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const scheduleItemContainerStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/frontend/src/features/timeline-schedule/api/index.ts b/frontend/apps/client/src/features/timeline-schedule/api/index.ts similarity index 90% rename from frontend/src/features/timeline-schedule/api/index.ts rename to frontend/apps/client/src/features/timeline-schedule/api/index.ts index 1168f090..24653391 100644 --- a/frontend/src/features/timeline-schedule/api/index.ts +++ b/frontend/apps/client/src/features/timeline-schedule/api/index.ts @@ -1,11 +1,11 @@ -import { request } from '@/utils/fetch'; +import { request } from '@utils/fetch'; import { CandidateDetailResponseSchema } from '../model'; // TODO: candidateApi와 통합 ? .. 합치는게 나을까 분리하는게 나을까 export const candidateDetailApi = { getCandidateScheduleDetail: async ( - discussionId: number, + discussionId: string, startDateTime: string, endDateTime: string, selectedUserIdList?: number[], diff --git a/frontend/src/features/timeline-schedule/api/keys.ts b/frontend/apps/client/src/features/timeline-schedule/api/keys.ts similarity index 91% rename from frontend/src/features/timeline-schedule/api/keys.ts rename to frontend/apps/client/src/features/timeline-schedule/api/keys.ts index 81c76050..de06dcd3 100644 --- a/frontend/src/features/timeline-schedule/api/keys.ts +++ b/frontend/apps/client/src/features/timeline-schedule/api/keys.ts @@ -3,7 +3,7 @@ const BASE_KEY = 'candidateSchedule'; export const candidateDetailQueryKey = { all: [BASE_KEY], detail: ( - discussionId: number, + discussionId: string, startDateTime: string, endDateTime: string, selectedUserIdList?: number[], diff --git a/frontend/src/features/timeline-schedule/api/queries.ts b/frontend/apps/client/src/features/timeline-schedule/api/queries.ts similarity index 94% rename from frontend/src/features/timeline-schedule/api/queries.ts rename to frontend/apps/client/src/features/timeline-schedule/api/queries.ts index 28021403..ca6802fb 100644 --- a/frontend/src/features/timeline-schedule/api/queries.ts +++ b/frontend/apps/client/src/features/timeline-schedule/api/queries.ts @@ -4,7 +4,7 @@ import type { CandidateDetailResponse } from '../model'; import { candidateDetailQueryOption } from './queryOptions'; export const useCandidateDetailQuery = ( - discussionId: number, + discussionId: string, startDateTime: string, endDateTime: string, selectedUserIdList?: number[], diff --git a/frontend/src/features/timeline-schedule/api/queryOptions.ts b/frontend/apps/client/src/features/timeline-schedule/api/queryOptions.ts similarity index 95% rename from frontend/src/features/timeline-schedule/api/queryOptions.ts rename to frontend/apps/client/src/features/timeline-schedule/api/queryOptions.ts index 46df0187..8bfc2bd3 100644 --- a/frontend/src/features/timeline-schedule/api/queryOptions.ts +++ b/frontend/apps/client/src/features/timeline-schedule/api/queryOptions.ts @@ -3,7 +3,7 @@ import { candidateDetailApi } from '.'; import { candidateDetailQueryKey } from './keys'; export const candidateDetailQueryOption = ( - discussionId: number, + discussionId: string, startDateTime: string, endDateTime: string, selectedUserIdList?: number[], diff --git a/frontend/src/features/timeline-schedule/model/index.ts b/frontend/apps/client/src/features/timeline-schedule/model/index.ts similarity index 72% rename from frontend/src/features/timeline-schedule/model/index.ts rename to frontend/apps/client/src/features/timeline-schedule/model/index.ts index cc89a3b7..fe7918a5 100644 --- a/frontend/src/features/timeline-schedule/model/index.ts +++ b/frontend/apps/client/src/features/timeline-schedule/model/index.ts @@ -1,14 +1,13 @@ +import { zCoerceToDate } from '@utils/zod'; import { z } from 'zod'; -import { zCoerceToDate } from '@/utils/zod'; - const ScheduleEventStatusSchema = z.union([ z.literal('ADJUSTABLE'), z.literal('FIXED'), z.literal('OUT_OF_RANGE'), ]); -const ScheduleEvent = z.object({ +const ScheduleEventSchema = z.object({ id: z.number(), startDateTime: zCoerceToDate, endDateTime: zCoerceToDate, @@ -16,13 +15,13 @@ const ScheduleEvent = z.object({ status: ScheduleEventStatusSchema, }); -const ParticipantSchema = z.object({ +export const ParticipantWithEventsSchema = z.object({ id: z.number(), name: z.string(), picture: z.string(), selected: z.boolean().optional(), requirementOfAdjustment: z.boolean(), - events: z.array(ScheduleEvent), + events: z.array(ScheduleEventSchema), }); export const CandidateDetailRequestSchema = z.object({ @@ -35,13 +34,13 @@ export const CandidateDetailResponseSchema = z.object({ discussionId: z.number(), startDateTime: zCoerceToDate, endDateTime: zCoerceToDate, - participants: z.array(ParticipantSchema), + participants: z.array(ParticipantWithEventsSchema), }); export type ScheduleEventStatus = z.infer<typeof ScheduleEventStatusSchema>; -export type ScheduleEvent = z.infer<typeof ScheduleEvent>; +export type ScheduleEvent = z.infer<typeof ScheduleEventSchema>; -export type Participant = z.infer<typeof ParticipantSchema>; +export type ParticipantWithEvents = z.infer<typeof ParticipantWithEventsSchema>; export type CandidateDetailRequest = z.infer<typeof CandidateDetailRequestSchema>; diff --git a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/ConflictRangeBox.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/ConflictRangeBox.tsx similarity index 100% rename from frontend/src/features/timeline-schedule/ui/TImelineCanvas/ConflictRangeBox.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/ConflictRangeBox.tsx diff --git a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx similarity index 87% rename from frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx index 451e70d4..ad619589 100644 --- a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineBlocks.tsx @@ -1,6 +1,6 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; -import type { Participant, ScheduleEvent } from '../../model'; +import type { ParticipantWithEvents, ScheduleEvent } from '../../model'; import { calculateBlockStyle } from '../timelineHelper'; import { timelineBlockContainerStyle, @@ -9,7 +9,7 @@ import { } from './index.css'; const TimelineBlocks = ({ participants, gridStart }: { - participants: Participant[]; + participants: ParticipantWithEvents[]; gridStart: Date; }) => ( <Flex diff --git a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx similarity index 93% rename from frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx index c513694d..893334d8 100644 --- a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/TimelineColumns.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; import { timelineColumnContainerStyle, timelineColumnStyle } from './index.css'; diff --git a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts similarity index 98% rename from frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts index 74b3c722..41c34408 100644 --- a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const timelineCanvasStyle = style({ position: 'static', width: '37.5rem', diff --git a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx similarity index 87% rename from frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx index dc770b11..deb61706 100644 --- a/frontend/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TImelineCanvas/index.tsx @@ -1,6 +1,6 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; -import type { Participant } from '../../model'; +import type { ParticipantWithEvents } from '../../model'; import ConflictRangeBox from './ConflictRangeBox'; import { timelineCanvasStyle } from './index.css'; import TimelineBlocks from './TimelineBlocks'; @@ -8,7 +8,7 @@ import TimelineColumns from './TimelineColumns'; const TimelineCanvas = ({ gridTimes, conflictStart, conflictEnd, participants, gridStartOffset }: { gridTimes: Date[]; - participants: Participant[]; + participants: ParticipantWithEvents[]; conflictStart: Date; conflictEnd: Date; gridStartOffset: number; diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx similarity index 85% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx index 84def45d..d72046c8 100644 --- a/frontend/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ChipAble.tsx @@ -1,7 +1,6 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; import { chipAbleContainerStyle } from './chipAble.css'; diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx similarity index 74% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx index 84e39566..708716c0 100644 --- a/frontend/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/ParticipantList.tsx @@ -1,34 +1,32 @@ -import Avatar from '@/components/Avatar'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; +import { Avatar, Flex, Text } from '@endolphin/ui'; -import type { Participant, ScheduleEventStatus } from '../../model'; +import type { ParticipantWithEvents, ScheduleEventStatus } from '../../model'; import { useTimelineContext } from '../TimelineContext'; import ChipAble from './ChipAble'; import { participantItemStyle, participantsContainerStyle } from './participantList.css'; interface ParticipantListProps { - checkedParticipants: Participant[]; - uncheckedParticipants: Participant[]; + selectedParticipants: ParticipantWithEvents[]; + unselectedParticipants: ParticipantWithEvents[]; } const ParticipantList = ({ - checkedParticipants, - uncheckedParticipants, + selectedParticipants, + unselectedParticipants, }: ParticipantListProps) => ( <Flex className={participantsContainerStyle} direction='column' gap={200} > - {checkedParticipants.map((participant) => ( + {selectedParticipants.map((participant) => ( <ParticipantItem isUncheckedParticipant={false} key={participant.id} participant={participant} /> ))} - {uncheckedParticipants.map((participant) => ( + {unselectedParticipants.map((participant) => ( <ParticipantItem isUncheckedParticipant={true} key={participant.id} @@ -39,7 +37,7 @@ const ParticipantList = ({ ); const ParticipantItem = ({ participant, isUncheckedParticipant }: { - participant: Participant; + participant: ParticipantWithEvents; isUncheckedParticipant: boolean; }) => { const { isConfirmedSchedule } = useTimelineContext(); @@ -62,7 +60,7 @@ const ParticipantItem = ({ participant, isUncheckedParticipant }: { ); }; -const getChipStatus = (participant: Participant): ScheduleEventStatus => { +const getChipStatus = (participant: ParticipantWithEvents): ScheduleEventStatus => { if (participant.events.some(event => event.status === 'FIXED')) return 'FIXED'; return participant.events.some(event => event.status === 'ADJUSTABLE') ? 'ADJUSTABLE' : 'OUT_OF_RANGE'; diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts similarity index 94% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts index ea8520e6..a416057d 100644 --- a/frontend/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/chipAble.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const chipAbleContainerStyle = recipe({ base: { padding: `${vars.spacing[200]} ${vars.spacing[300]}`, diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/index.css.ts b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.css.ts similarity index 96% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/index.css.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.css.ts index 8bded97a..b9e99474 100644 --- a/frontend/src/features/timeline-schedule/ui/TimelineContent/index.css.ts +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/index.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.tsx similarity index 78% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/index.tsx rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.tsx index fe983103..4d1fab0c 100644 --- a/frontend/src/features/timeline-schedule/ui/TimelineContent/index.tsx +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/index.tsx @@ -1,12 +1,9 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Text, Tooltip } from '@endolphin/ui'; import { useRef } from 'react'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import Tooltip from '@/components/Tooltip'; -import { vars } from '@/theme/index.css'; - -import type { Participant } from '../../model'; +import type { ParticipantWithEvents } from '../../model'; import TimelineCanvas from '../TImelineCanvas'; import { calculateDimHeight, getGridTimes, getRowTopOffset } from '../timelineHelper'; import { @@ -22,8 +19,8 @@ import ParticipantList from './ParticipantList'; interface TimelineContentProps { conflictStart: Date; conflictEnd: Date; - checkedParticipants: Participant[]; - uncheckedParticipants: Participant[]; + selectedParticipants: ParticipantWithEvents[]; + unselectedParticipants: ParticipantWithEvents[]; } const TimelineContent = (props: TimelineContentProps) => { @@ -44,17 +41,17 @@ const TimelineContent = (props: TimelineContentProps) => { {...props} gridStartOffset={gridStartOffset} gridTimes={gridTimes} - participants={[...props.checkedParticipants, ...props.uncheckedParticipants]} + participants={[...props.selectedParticipants, ...props.unselectedParticipants]} /> - {props.uncheckedParticipants.length > 0 && + {props.unselectedParticipants.length > 0 && <div className={dimStyle} ref={dimRef} style={{ - top: getRowTopOffset(props.checkedParticipants.length), + top: getRowTopOffset(props.selectedParticipants.length), height: calculateDimHeight( - props.checkedParticipants.length, - props.uncheckedParticipants.length, + props.selectedParticipants.length, + props.unselectedParticipants.length, ), }} />} diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContent/participantList.css.ts b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/participantList.css.ts similarity index 100% rename from frontend/src/features/timeline-schedule/ui/TimelineContent/participantList.css.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/TimelineContent/participantList.css.ts diff --git a/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContext.ts b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContext.ts new file mode 100644 index 00000000..5edce9d6 --- /dev/null +++ b/frontend/apps/client/src/features/timeline-schedule/ui/TimelineContext.ts @@ -0,0 +1,11 @@ +import { createStateContext } from '@endolphin/core/utils'; + +interface TimelineContextProps { + isConfirmedSchedule: boolean; + handleGoBack: () => void; +} + +export const { + StateProvider: TimelineProvider, + useContextState: useTimelineContext, +} = createStateContext<TimelineContextProps, TimelineContextProps, object>(); \ No newline at end of file diff --git a/frontend/src/features/timeline-schedule/ui/index.css.ts b/frontend/apps/client/src/features/timeline-schedule/ui/index.css.ts similarity index 95% rename from frontend/src/features/timeline-schedule/ui/index.css.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/index.css.ts index 46d9ad6c..7dce07d7 100644 --- a/frontend/src/features/timeline-schedule/ui/index.css.ts +++ b/frontend/apps/client/src/features/timeline-schedule/ui/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/frontend/apps/client/src/features/timeline-schedule/ui/index.tsx b/frontend/apps/client/src/features/timeline-schedule/ui/index.tsx new file mode 100644 index 00000000..cfe43a3e --- /dev/null +++ b/frontend/apps/client/src/features/timeline-schedule/ui/index.tsx @@ -0,0 +1,112 @@ +import { vars } from '@endolphin/theme'; +import { Flex, Icon } from '@endolphin/ui'; +import { useCanGoBack, useRouter } from '@tanstack/react-router'; +import type { PropsWithChildren } from 'react'; + +import type { ParticipantWithEvents } from '../model'; +import { + closeButtonStyle, + containerStyle, + contentContainerStyle, + headerStyle, + topBarStyle, +} from './index.css'; +import TimelineContent from './TimelineContent'; +import { TimelineProvider, useTimelineContext } from './TimelineContext'; +import { splitParticipantsBySelection } from './timelineHelper'; + +// TODO: context로 옮길 수 있는 prop들 찾아서 옮기기 +interface TimelineScheduleModalProps extends PropsWithChildren { + startDateTime: Date; + endDateTime: Date; + participantWithEventsList: ParticipantWithEvents[]; + isConfirmedSchedule: boolean; +} + +const TimelineScheduleModal = ({ + startDateTime, + endDateTime, + participantWithEventsList, + isConfirmedSchedule, + children, +}: TimelineScheduleModalProps) => { + const router = useRouter(); + const canGoBack = useCanGoBack(); + const handleGoBack = () => canGoBack && router.history.back(); + + return ( + <TimelineProvider initialValue={{ isConfirmedSchedule, handleGoBack }}> + <div className={containerStyle}> + {children} + <Content + endDateTime={endDateTime} + participants={participantWithEventsList} + startDateTime={startDateTime} + /> + </div> + </TimelineProvider> + ); +}; + +const TopBar = ({ children }: PropsWithChildren) =>{ + const { handleGoBack } = useTimelineContext(); + return ( + <Flex + align='center' + className={topBarStyle} + justify='flex-start' + > + {children} + <Icon.Close + className={closeButtonStyle} + clickable + fill={vars.color.Ref.Netural[500]} + onClick={handleGoBack} + width={24} + /> + </Flex> + ); +}; + +interface ContentProps { + startDateTime: Date; + endDateTime: Date; + participants: ParticipantWithEvents[]; +} + +const Content = ({ + startDateTime, + endDateTime, + participants, +}: ContentProps) => { + const { + selectedParticipants, + unselectedParticipants, + } = splitParticipantsBySelection(participants); + return ( + <Flex + className={contentContainerStyle} + direction='column' + justify='flex-start' + width='full' + > + <TimelineContent + conflictEnd={endDateTime} + conflictStart={startDateTime} + selectedParticipants={selectedParticipants} + unselectedParticipants={unselectedParticipants} + /> + </Flex> + ); +}; + +const Header = ({ children }: PropsWithChildren) => ( + <div className={headerStyle}> + {children} + </div> +); + +TimelineScheduleModal.TopBar = TopBar; +TimelineScheduleModal.Header = Header; + +export default TimelineScheduleModal; \ No newline at end of file diff --git a/frontend/src/features/timeline-schedule/ui/timelineHelper.ts b/frontend/apps/client/src/features/timeline-schedule/ui/timelineHelper.ts similarity index 80% rename from frontend/src/features/timeline-schedule/ui/timelineHelper.ts rename to frontend/apps/client/src/features/timeline-schedule/ui/timelineHelper.ts index e162703b..21f048c1 100644 --- a/frontend/src/features/timeline-schedule/ui/timelineHelper.ts +++ b/frontend/apps/client/src/features/timeline-schedule/ui/timelineHelper.ts @@ -1,6 +1,6 @@ -import { getMinuteDiff, MINUTE_IN_MILLISECONDS } from '@/utils/date'; +import { getMinuteDiff, MINUTE_IN_MILLISECONDS } from '@endolphin/core/utils'; -import type { Participant } from '../model'; +import type { ParticipantWithEvents } from '../model'; const GRID_COLUMN_WIDTH = 34; const MINUTES_PER_GRID = 30; @@ -11,23 +11,19 @@ const PIXELS_PER_MINUTE = GRID_COLUMN_WIDTH / MINUTES_PER_GRID; // ########## Helpers for Processing Data ########## export const splitParticipantsBySelection = ( - participants: Participant[], selectedParticipantIds?: number[], + participants: ParticipantWithEvents[], ) => { - if (!selectedParticipantIds) { - return { checkedParticipants: participants, uncheckedParticipants: [] }; - } - - const uncheckedParticipants: Participant[] = []; - const checkedParticipants: Participant[] = []; - participants.forEach(participant => { - if (selectedParticipantIds.includes(participant.id)) { - checkedParticipants.push(participant); + const selectedParticipants: ParticipantWithEvents[] = []; + const unselectedParticipants: ParticipantWithEvents[] = []; + participants.forEach((participant) => { + if (participant.selected) { + selectedParticipants.push(participant); } else { - uncheckedParticipants.push(participant); + unselectedParticipants.push(participant); } }); - return { checkedParticipants, uncheckedParticipants }; + return { selectedParticipants, unselectedParticipants }; }; export const calculateMiddleTime = (startTime: Date, endTime: Date): Date => { diff --git a/frontend/src/features/user/api/index.ts b/frontend/apps/client/src/features/user/api/index.ts similarity index 92% rename from frontend/src/features/user/api/index.ts rename to frontend/apps/client/src/features/user/api/index.ts index ddad663d..755a11e4 100644 --- a/frontend/src/features/user/api/index.ts +++ b/frontend/apps/client/src/features/user/api/index.ts @@ -1,4 +1,4 @@ -import { request } from '@/utils/fetch'; +import { request } from '@utils/fetch'; import type { UserInfo, UserNicknameRequest } from '../model'; import { UserInfoSchema } from '../model'; diff --git a/frontend/src/features/user/api/keys.ts b/frontend/apps/client/src/features/user/api/keys.ts similarity index 100% rename from frontend/src/features/user/api/keys.ts rename to frontend/apps/client/src/features/user/api/keys.ts diff --git a/frontend/src/features/user/api/mutations.ts b/frontend/apps/client/src/features/user/api/mutations.ts similarity index 78% rename from frontend/src/features/user/api/mutations.ts rename to frontend/apps/client/src/features/user/api/mutations.ts index f89eb456..71d29af5 100644 --- a/frontend/src/features/user/api/mutations.ts +++ b/frontend/apps/client/src/features/user/api/mutations.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { getAccessToken } from '@/utils/auth'; +import { accessTokenService } from '@utils/auth/accessTokenService'; import type { UserNicknameRequest } from '../model'; import { userApi } from '.'; @@ -12,7 +11,7 @@ export const useNicknameMutation = () => { mutationFn: ({ name }: UserNicknameRequest ) => userApi.patchNickname({ name }), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: userInfoQueryKey(getAccessToken()), + queryKey: userInfoQueryKey(accessTokenService.getAccessToken()), }); }, }); diff --git a/frontend/src/features/user/api/queries.ts b/frontend/apps/client/src/features/user/api/queries.ts similarity index 61% rename from frontend/src/features/user/api/queries.ts rename to frontend/apps/client/src/features/user/api/queries.ts index 4f03219c..0fad272a 100644 --- a/frontend/src/features/user/api/queries.ts +++ b/frontend/apps/client/src/features/user/api/queries.ts @@ -1,11 +1,10 @@ import { useQuery } from '@tanstack/react-query'; - -import { getAccessToken } from '@/utils/auth'; +import { accessTokenService } from '@utils/auth/accessTokenService'; import { userApi } from '.'; import { userInfoQueryKey } from './keys'; export const useUserInfoQuery = () => useQuery({ - queryKey: userInfoQueryKey(getAccessToken()), + queryKey: userInfoQueryKey(accessTokenService.getAccessToken()), queryFn: () => userApi.getUserInfo(), }); diff --git a/frontend/src/features/user/model/index.ts b/frontend/apps/client/src/features/user/model/index.ts similarity index 100% rename from frontend/src/features/user/model/index.ts rename to frontend/apps/client/src/features/user/model/index.ts diff --git a/frontend/src/hooks/useCarousel.ts b/frontend/apps/client/src/hooks/useCarousel.ts similarity index 100% rename from frontend/src/hooks/useCarousel.ts rename to frontend/apps/client/src/hooks/useCarousel.ts diff --git a/frontend/src/hooks/useClipboard.ts b/frontend/apps/client/src/hooks/useClipboard.ts similarity index 100% rename from frontend/src/hooks/useClipboard.ts rename to frontend/apps/client/src/hooks/useClipboard.ts diff --git a/frontend/apps/client/src/hooks/useCountdown.ts b/frontend/apps/client/src/hooks/useCountdown.ts new file mode 100644 index 00000000..4b2a742b --- /dev/null +++ b/frontend/apps/client/src/hooks/useCountdown.ts @@ -0,0 +1,25 @@ +import { SECOND_IN_MILLISECONDS } from '@endolphin/core/utils'; +import { useEffect, useState } from 'react'; + +const useCountdown = (targetDateTime: Date, onTimeEnd?: () => void): number => { + const [remainingTime, setRemainingTime] = useState<number>( + Math.max(targetDateTime.getTime() - Date.now(), 0), + ); + + useEffect(() => { + const interval = setInterval(() => { + const remaining = Math.max(targetDateTime.getTime() - Date.now(), 0); + setRemainingTime(remaining); + if (remaining === 0) { + clearInterval(interval); + onTimeEnd?.(); + } + }, SECOND_IN_MILLISECONDS); + + return () => clearInterval(interval); + }, [targetDateTime, onTimeEnd]); + + return remainingTime; +}; + +export default useCountdown; diff --git a/frontend/apps/client/src/hooks/useFormRef.ts b/frontend/apps/client/src/hooks/useFormRef.ts new file mode 100644 index 00000000..c116cefb --- /dev/null +++ b/frontend/apps/client/src/hooks/useFormRef.ts @@ -0,0 +1,55 @@ +import { useRef, useState } from 'react'; + +// Form의 value로는 다양한 값이 올 수 있습니다. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type FormValues<T> = { [K in keyof T]: any }; +export type ChangeEvent<T> = { name: keyof T; value: T[keyof T] }; + +export interface FormRef<T> { + valuesRef: { current: FormValues<T> }; + handleChange: ({ name, value }: ChangeEvent<T>) => void; + setValidation: (key: keyof T, validateFn: (value: T[keyof T]) => string | null) => void; + handleSubmit: (callback: () => void) => void; + isValid: (key: keyof T) => boolean; + errors: (key: keyof T) => string; +} + +export type Validation<T> = Record<keyof T, <K extends keyof T>(value: T[K]) => string | null>; + +export const useFormRef = <T>(initialValues: FormValues<T>): FormRef<T> => { + const valuesRef = useRef<FormValues<T>>({ ...initialValues }); + const validationRef = useRef<Validation<T>>({} as Validation<T>); + const [error, setError] = useState<Record<keyof T, string>>({} as Record<keyof T, string>); + + const handleChange = ({ name, value }: ChangeEvent<T>) => { + valuesRef.current[name] = value; + }; + + const setValidation = (key: keyof T, validateFn: (value: T[keyof T]) => string | null) => { + validationRef.current[key] = validateFn; + }; + + const errors = (key: keyof T) => error[key]; + + const isValid = (key: keyof T) => + !validationRef.current[key] || !validationRef.current[key](valuesRef.current[key]); + + const isValidForm = () => + Object.keys(validationRef.current).every((key) => isValid(key as keyof T)); + + const handleSubmit = (callback: () => void) => { + if (!isValidForm()) { + const newError = Object.keys(validationRef.current).reduce( + (acc, key) => { + const error = validationRef.current[key as keyof T](valuesRef.current[key as keyof T]); + if (error) acc[key as keyof T] = error; + return acc; + }, {} as Record<keyof T, string>); + setError(newError); + return; + } + callback(); + }; + + return { valuesRef, handleChange, setValidation, isValid, errors, handleSubmit }; +}; diff --git a/frontend/src/hooks/useFormState.ts b/frontend/apps/client/src/hooks/useFormState.ts similarity index 100% rename from frontend/src/hooks/useFormState.ts rename to frontend/apps/client/src/hooks/useFormState.ts diff --git a/frontend/src/hooks/useGroup.ts b/frontend/apps/client/src/hooks/useGroup.ts similarity index 100% rename from frontend/src/hooks/useGroup.ts rename to frontend/apps/client/src/hooks/useGroup.ts diff --git a/frontend/src/hooks/useModal.ts b/frontend/apps/client/src/hooks/useModal.ts similarity index 92% rename from frontend/src/hooks/useModal.ts rename to frontend/apps/client/src/hooks/useModal.ts index 04ba241d..5355db22 100644 --- a/frontend/src/hooks/useModal.ts +++ b/frontend/apps/client/src/hooks/useModal.ts @@ -1,7 +1,6 @@ +import type { ModalProps } from '@components/Modal'; import { useState } from 'react'; -import type { ModalProps } from '@/components/Modal'; - export type ModalWithoutIsOpen = Omit<ModalProps, 'isOpen'>; export interface ModalInfo { diff --git a/frontend/apps/client/src/hooks/useNavigateToCandidate.ts b/frontend/apps/client/src/hooks/useNavigateToCandidate.ts new file mode 100644 index 00000000..6d5616a8 --- /dev/null +++ b/frontend/apps/client/src/hooks/useNavigateToCandidate.ts @@ -0,0 +1,26 @@ +import { useNavigate, useParams } from '@tanstack/react-router'; +import { useAtom } from 'jotai'; + +import type { DiscussionDTO } from '@/features/discussion/model'; +import { checkboxAtom } from '@/store/discussion'; + +export const useNavigateToCandidate = (discussion: DiscussionDTO) => { + const { id } = useParams({ from: '/_main/discussion/$id' }); + const [checkboxSelectedList] = useAtom(checkboxAtom); + const navigate = useNavigate(); + + const handleNavigateToCandidate = () => { + navigate({ + params: { id: id }, + state: { candidate: { + adjustCount: discussion.usersForAdjust.length, + startDateTime: discussion.startDateTime, + endDateTime: discussion.endDateTime, + selectedParticipantIds: checkboxSelectedList ?? undefined, + } }, + to: '/discussion/candidate/$id', + }); + }; + + return { handleNavigateToCandidate }; +}; \ No newline at end of file diff --git a/frontend/apps/client/src/hooks/useScrollToTime.ts b/frontend/apps/client/src/hooks/useScrollToTime.ts new file mode 100644 index 00000000..a8b1613d --- /dev/null +++ b/frontend/apps/client/src/hooks/useScrollToTime.ts @@ -0,0 +1,31 @@ +import { TIME_HEIGHT } from '@constants/date'; +import { getTimeParts, type Time } from '@endolphin/core/utils'; +import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +export interface ScrollTableProps { + tableRef: RefObject<HTMLDivElement | null>; + height: number; + handleSelectTime: (time: Time) => void; +} + +export const useScrollToTime = (): ScrollTableProps => { + const tableRef = useRef<HTMLDivElement | null>(null); + const [time, setTime] = useState<Time>(getTimeParts(new Date())); + const offset = 6.5 + (time.hour + time.minute / 60) * TIME_HEIGHT; + + useEffect(() => { + if (time && tableRef.current) { + tableRef.current.scrollTo({ + top: offset - 5 * TIME_HEIGHT, + behavior: 'smooth', + }); + } + }, [time, offset]); + + const handleSelectTime = ({ hour, minute }: Time) => { + setTime({ hour, minute, second: 0 }); + }; + + return { tableRef, height: offset, handleSelectTime }; +}; diff --git a/frontend/src/hooks/useSelectDateRange.ts b/frontend/apps/client/src/hooks/useSelectDateRange.ts similarity index 100% rename from frontend/src/hooks/useSelectDateRange.ts rename to frontend/apps/client/src/hooks/useSelectDateRange.ts diff --git a/frontend/apps/client/src/layout/Footer/index.css.ts b/frontend/apps/client/src/layout/Footer/index.css.ts new file mode 100644 index 00000000..673245c3 --- /dev/null +++ b/frontend/apps/client/src/layout/Footer/index.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const footerContainerStyle = style({ + padding: '0.5rem 1rem', + position: 'fixed', + bottom: 0, + left: 0, + + pointerEvents: 'none', +}); \ No newline at end of file diff --git a/frontend/apps/client/src/layout/Footer/index.tsx b/frontend/apps/client/src/layout/Footer/index.tsx new file mode 100644 index 00000000..d9f63ae6 --- /dev/null +++ b/frontend/apps/client/src/layout/Footer/index.tsx @@ -0,0 +1,24 @@ +import { Button, Flex } from '@endolphin/ui'; +import { Link } from '@tanstack/react-router'; + +import { footerContainerStyle } from './index.css'; + +const Footer = () => ( + <Flex + as='footer' + className={footerContainerStyle} + justify='flex-start' + width='100%' + > + <Button + as={Link} + size='sm' + target='_blank' + to='/service/privacy' + > + 개인정보처리방침 + </Button> + </Flex> +); + +export default Footer; \ No newline at end of file diff --git a/frontend/apps/client/src/layout/GlobalNavBar/BaseContainer.tsx b/frontend/apps/client/src/layout/GlobalNavBar/BaseContainer.tsx new file mode 100644 index 00000000..3486ec0a --- /dev/null +++ b/frontend/apps/client/src/layout/GlobalNavBar/BaseContainer.tsx @@ -0,0 +1,25 @@ +import { Flex, Icon } from '@endolphin/ui'; +import type { PropsWithChildren } from 'react'; + +import { containerStyle, logoWrapperStyle } from './index.css'; + +interface BaseContainerProps extends PropsWithChildren { + background: 'white' | 'transparent'; + onClickLogo: () => void; +} + +export const BaseContainer = ({ background, onClickLogo, children }: BaseContainerProps) => + <header className={containerStyle({ background })}> + <div className={logoWrapperStyle} onClick={onClickLogo}> + <Icon.Logo + clickable={true} + height={22} + width={80} + /> + </div> + <Flex direction='row'> + {children} + </Flex> + </header>; + +export default BaseContainer; diff --git a/frontend/src/layout/GlobalNavBar/UserAvatar.tsx b/frontend/apps/client/src/layout/GlobalNavBar/UserAvatar.tsx similarity index 88% rename from frontend/src/layout/GlobalNavBar/UserAvatar.tsx rename to frontend/apps/client/src/layout/GlobalNavBar/UserAvatar.tsx index a2c0a342..62314205 100644 --- a/frontend/src/layout/GlobalNavBar/UserAvatar.tsx +++ b/frontend/apps/client/src/layout/GlobalNavBar/UserAvatar.tsx @@ -1,16 +1,11 @@ +import { Avatar, Button, Dropdown, Flex, Input, Text } from '@endolphin/ui'; +import { useFormRef } from '@hooks/useFormRef'; import { useNavigate } from '@tanstack/react-router'; +import { logout } from '@utils/auth'; -import Avatar from '@/components/Avatar'; -import Button from '@/components/Button'; -import { Dropdown } from '@/components/Dropdown'; -import { Flex } from '@/components/Flex'; -import Input from '@/components/Input'; -import { Text } from '@/components/Text'; import { useNicknameMutation } from '@/features/user/api/mutations'; import { useUserInfoQuery } from '@/features/user/api/queries'; -import { useFormRef } from '@/hooks/useFormRef'; import { useGlobalModal } from '@/store/global/modal'; -import { logout } from '@/utils/auth'; import { avatarContainerStyle, diff --git a/frontend/src/layout/GlobalNavBar/buttons/LoginLink.tsx b/frontend/apps/client/src/layout/GlobalNavBar/buttons/LoginLink.tsx similarity index 78% rename from frontend/src/layout/GlobalNavBar/buttons/LoginLink.tsx rename to frontend/apps/client/src/layout/GlobalNavBar/buttons/LoginLink.tsx index d8d1da90..2f332e35 100644 --- a/frontend/src/layout/GlobalNavBar/buttons/LoginLink.tsx +++ b/frontend/apps/client/src/layout/GlobalNavBar/buttons/LoginLink.tsx @@ -1,7 +1,6 @@ +import { Button } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Button from '@/components/Button'; - export const LoginLink = () => ( <Button as={Link} diff --git a/frontend/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx b/frontend/apps/client/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx similarity index 84% rename from frontend/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx rename to frontend/apps/client/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx index 5917642f..c6551706 100644 --- a/frontend/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx +++ b/frontend/apps/client/src/layout/GlobalNavBar/buttons/MyCalendarLink.tsx @@ -1,7 +1,6 @@ +import { Button } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Button from '@/components/Button'; - export const MyCalendarLink = () => ( <Button as={Link} diff --git a/frontend/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx b/frontend/apps/client/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx similarity index 86% rename from frontend/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx rename to frontend/apps/client/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx index 95e3fe2f..f650cce0 100644 --- a/frontend/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx +++ b/frontend/apps/client/src/layout/GlobalNavBar/buttons/NewDiscussionLink.tsx @@ -1,7 +1,6 @@ +import { Button } from '@endolphin/ui'; import { Link } from '@tanstack/react-router'; -import Button from '@/components/Button'; - export const NewDiscussionLink = () => ( <Button as={Link} diff --git a/frontend/src/layout/GlobalNavBar/buttons/index.ts b/frontend/apps/client/src/layout/GlobalNavBar/buttons/index.ts similarity index 100% rename from frontend/src/layout/GlobalNavBar/buttons/index.ts rename to frontend/apps/client/src/layout/GlobalNavBar/buttons/index.ts diff --git a/frontend/src/layout/GlobalNavBar/index.css.ts b/frontend/apps/client/src/layout/GlobalNavBar/index.css.ts similarity index 80% rename from frontend/src/layout/GlobalNavBar/index.css.ts rename to frontend/apps/client/src/layout/GlobalNavBar/index.css.ts index ec0cb2b7..1207475d 100644 --- a/frontend/src/layout/GlobalNavBar/index.css.ts +++ b/frontend/apps/client/src/layout/GlobalNavBar/index.css.ts @@ -1,10 +1,10 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const avatarContainerStyle = style({ marginLeft: vars.spacing[300], + cursor: 'pointer', }); export const containerStyle = recipe({ @@ -18,13 +18,11 @@ export const containerStyle = recipe({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '0 28px', + padding: '0 28px 0 16px', height: 56, borderBottom: `1px solid ${vars.color.Ref.Netural[100]}`, zIndex: 1000, - - cursor: 'pointer', }, variants: { background: { @@ -59,4 +57,12 @@ export const nicknameTextStyle = style({ export const nicknameModalContentsStyle = style({ paddingTop: vars.spacing[600], -}); \ No newline at end of file +}); + +export const logoWrapperStyle = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: `${vars.spacing[300]}`, + cursor: 'pointer', +}); diff --git a/frontend/src/layout/GlobalNavBar/index.tsx b/frontend/apps/client/src/layout/GlobalNavBar/index.tsx similarity index 52% rename from frontend/src/layout/GlobalNavBar/index.tsx rename to frontend/apps/client/src/layout/GlobalNavBar/index.tsx index d6f11944..3b4cf65e 100644 --- a/frontend/src/layout/GlobalNavBar/index.tsx +++ b/frontend/apps/client/src/layout/GlobalNavBar/index.tsx @@ -1,13 +1,12 @@ +import { Flex } from '@endolphin/ui'; import { useNavigate } from '@tanstack/react-router'; -import type { PropsWithChildren } from 'react'; +import { type PropsWithChildren } from 'react'; -import { Flex } from '@/components/Flex'; -import { Logo } from '@/components/Icon/component/Logo'; import { isLogin } from '@/utils/auth'; +import BaseContainer from './BaseContainer'; import { LoginLink, MyCalendarLink, NewDiscussionLink } from './buttons'; -import { containerStyle } from './index.css'; import { UserAvatar } from './UserAvatar'; interface GlobalNavBarProps extends PropsWithChildren { @@ -21,27 +20,22 @@ const GlobalNavBar = ({ background = 'white', children }: GlobalNavBarProps) => navigate({ to: '/', replace: true }); }; + if (!isLogin()) return ( + <BaseContainer background={background} onClickLogo={onClickLogo}> + <LoginLink /> + </BaseContainer> + ); + return ( - <header className={containerStyle({ background })}> - <Logo - clickable={true} - height={22} - onClick={onClickLogo} - width={80} - /> - <Flex direction='row'> - {isLogin() ? - <Flex - align='center' - direction='row' - > - {children} - <UserAvatar /> - </Flex> - : - <LoginLink />} + <BaseContainer background={background} onClickLogo={onClickLogo}> + <Flex + align='center' + direction='row' + > + {children} + <UserAvatar /> </Flex> - </header> + </BaseContainer> ); }; diff --git a/frontend/src/layout/MainLayout/index.css.ts b/frontend/apps/client/src/layout/MainLayout/index.css.ts similarity index 100% rename from frontend/src/layout/MainLayout/index.css.ts rename to frontend/apps/client/src/layout/MainLayout/index.css.ts diff --git a/frontend/src/layout/MainLayout/index.tsx b/frontend/apps/client/src/layout/MainLayout/index.tsx similarity index 100% rename from frontend/src/layout/MainLayout/index.tsx rename to frontend/apps/client/src/layout/MainLayout/index.tsx diff --git a/frontend/src/main.tsx b/frontend/apps/client/src/main.tsx similarity index 93% rename from frontend/src/main.tsx rename to frontend/apps/client/src/main.tsx index 5f3daf4d..c54eb488 100644 --- a/frontend/src/main.tsx +++ b/frontend/apps/client/src/main.tsx @@ -1,6 +1,3 @@ -import './theme/index.css'; -import './theme/reset.css'; - import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createRouter, RouterProvider } from '@tanstack/react-router'; import { StrictMode } from 'react'; @@ -13,10 +10,14 @@ import { handleError } from './utils/error/handleError'; const queryClient = new QueryClient({ defaultOptions: { queries: { + retry: 1, staleTime: Infinity, + networkMode: 'always', + throwOnError: handleError, }, mutations: { onError: handleError, + networkMode: 'always', }, }, queryCache: new QueryCache({ diff --git a/frontend/src/pages/CandidateSchedulePage/Header.tsx b/frontend/apps/client/src/pages/CandidateSchedulePage/Header.tsx similarity index 86% rename from frontend/src/pages/CandidateSchedulePage/Header.tsx rename to frontend/apps/client/src/pages/CandidateSchedulePage/Header.tsx index 3accd880..e143413e 100644 --- a/frontend/src/pages/CandidateSchedulePage/Header.tsx +++ b/frontend/apps/client/src/pages/CandidateSchedulePage/Header.tsx @@ -1,13 +1,13 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; +import { formatTimeToColonString } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; + import DiscussionConfirmButton from '@/features/discussion/ui/DiscussionConfirmButton'; -import { vars } from '@/theme/index.css'; -import { formatTimeToColonString } from '@/utils/date/format'; interface HeaderProps { adjustCount: number; - discussionId: number; + discussionId: string; startDateTime: string; endDateTime: string; } diff --git a/frontend/src/pages/CandidateSchedulePage/index.css.ts b/frontend/apps/client/src/pages/CandidateSchedulePage/index.css.ts similarity index 100% rename from frontend/src/pages/CandidateSchedulePage/index.css.ts rename to frontend/apps/client/src/pages/CandidateSchedulePage/index.css.ts diff --git a/frontend/src/pages/CandidateSchedulePage/index.tsx b/frontend/apps/client/src/pages/CandidateSchedulePage/index.tsx similarity index 55% rename from frontend/src/pages/CandidateSchedulePage/index.tsx rename to frontend/apps/client/src/pages/CandidateSchedulePage/index.tsx index dccace74..b4f32643 100644 --- a/frontend/src/pages/CandidateSchedulePage/index.tsx +++ b/frontend/apps/client/src/pages/CandidateSchedulePage/index.tsx @@ -1,5 +1,6 @@ import { useParams } from '@tanstack/react-router'; +import { useCandidateDetailQuery } from '@/features/timeline-schedule/api/queries'; import TimelineScheduleModal from '@/features/timeline-schedule/ui'; import Header from './Header'; @@ -11,21 +12,32 @@ const CandidateSchedulePage = (candidate: { endDateTime: string; selectedParticipantIds?: number[]; }) => { - const { id } = useParams({ from: '/_main/discussion/candidate/$id' }); + const { id: discussionId } = useParams({ from: '/_main/discussion/candidate/$id' }); + + const { data, isPending } = useCandidateDetailQuery( + discussionId, + candidate.startDateTime, + candidate.endDateTime, + candidate.selectedParticipantIds, + ); + + // TODO: 예외 핸들링 + if (isPending || !data) return <div className={backdropStyle} />; return ( <> <div className={backdropStyle} /> <TimelineScheduleModal - discussionId={Number(id)} - {...candidate} + endDateTime={data.endDateTime} isConfirmedSchedule={false} + participantWithEventsList={data.participants} + startDateTime={data.startDateTime} > - <TimelineScheduleModal.TopBar></TimelineScheduleModal.TopBar> + <TimelineScheduleModal.TopBar /> <TimelineScheduleModal.Header> <Header adjustCount={candidate.adjustCount} - discussionId={Number(id)} + discussionId={discussionId} endDateTime={candidate.endDateTime} startDateTime={candidate.startDateTime} /> diff --git a/frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts b/frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts similarity index 88% rename from frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts index f3019100..c74daf71 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ position: 'fixed', top: 0, diff --git a/frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx similarity index 88% rename from frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx index f77baa56..a662f7be 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionConfirmPage/index.tsx @@ -1,9 +1,9 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; + import type { DiscussionConfirmResponse } from '@/features/discussion/model'; import DiscussionConfirmCard from '@/features/discussion/ui/DiscussionConfirmCard'; -import { vars } from '@/theme/index.css'; import { containerStyle, subtitleStyle, titleStyle } from './index.css'; diff --git a/frontend/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.css.ts b/frontend/apps/client/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.css.ts similarity index 100% rename from frontend/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.css.ts rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.css.ts diff --git a/frontend/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx similarity index 91% rename from frontend/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx index 5888a1f5..059c2f45 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionCreateFinishPage/index.tsx @@ -1,4 +1,5 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; + import type { DiscussionResponse } from '@/features/discussion/model'; import DiscussionCreateCard from '@/features/discussion/ui/DiscussionCreateCard'; diff --git a/frontend/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx similarity index 93% rename from frontend/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx index d6d2d686..25cd849c 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionCreatePage/index.tsx @@ -1,4 +1,5 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; + import DiscussionCreateTitle from '@/features/discussion/ui/DiscussionCreateTitle'; import DiscussionForm from '@/features/discussion/ui/DiscussionForm'; diff --git a/frontend/src/pages/DiscussionPage/DiscussionEditPage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/DiscussionEditPage/index.tsx similarity index 95% rename from frontend/src/pages/DiscussionPage/DiscussionEditPage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionEditPage/index.tsx index 58955ba1..52689678 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionEditPage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionEditPage/index.tsx @@ -1,4 +1,5 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; + import DiscussionCreateTitle from '@/features/discussion/ui/DiscussionCreateTitle'; import DiscussionForm from '@/features/discussion/ui/DiscussionForm'; diff --git a/frontend/src/pages/DiscussionPage/DiscussionInvitePage/index.css.ts b/frontend/apps/client/src/pages/DiscussionPage/DiscussionInvitePage/index.css.ts similarity index 100% rename from frontend/src/pages/DiscussionPage/DiscussionInvitePage/index.css.ts rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionInvitePage/index.css.ts diff --git a/frontend/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx similarity index 94% rename from frontend/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx index 8a3fcdd3..4b9fd693 100644 --- a/frontend/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/DiscussionInvitePage/index.tsx @@ -5,7 +5,6 @@ import DiscussionInviteCard from '@/features/discussion/ui/DiscussionInviteCard' const DiscussionInvitePage = ({ invitation }: { invitation: InviteResponse }) => { const { id } = useParams({ from: '/_main/discussion/invite/$id' }); - const { host, title, @@ -16,6 +15,7 @@ const DiscussionInvitePage = ({ invitation }: { invitation: InviteResponse }) => duration, isFull, requirePassword, + timeUnlocked, } = invitation; return ( @@ -27,6 +27,7 @@ const DiscussionInvitePage = ({ invitation }: { invitation: InviteResponse }) => meetingDuration={duration} requirePassword={requirePassword} timeRange={{ start: timeRangeStart, end: timeRangeEnd }} + timeUnlocked={timeUnlocked} title={title} /> ); diff --git a/frontend/src/pages/DiscussionPage/index.css.ts b/frontend/apps/client/src/pages/DiscussionPage/index.css.ts similarity index 100% rename from frontend/src/pages/DiscussionPage/index.css.ts rename to frontend/apps/client/src/pages/DiscussionPage/index.css.ts diff --git a/frontend/src/pages/DiscussionPage/index.tsx b/frontend/apps/client/src/pages/DiscussionPage/index.tsx similarity index 94% rename from frontend/src/pages/DiscussionPage/index.tsx rename to frontend/apps/client/src/pages/DiscussionPage/index.tsx index 7d99451d..fc79008f 100644 --- a/frontend/src/pages/DiscussionPage/index.tsx +++ b/frontend/apps/client/src/pages/DiscussionPage/index.tsx @@ -1,4 +1,5 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; + import DiscussionMemberCheckbox from '@/features/discussion/ui/DiscussionMemberCheckbox'; import DiscussionTab from '@/features/discussion/ui/DiscussionTab'; import DiscussionTitle from '@/features/discussion/ui/DiscussionTitle'; diff --git a/frontend/apps/client/src/pages/ErrorPage/index.tsx b/frontend/apps/client/src/pages/ErrorPage/index.tsx new file mode 100644 index 00000000..33032ff6 --- /dev/null +++ b/frontend/apps/client/src/pages/ErrorPage/index.tsx @@ -0,0 +1,49 @@ +import { vars } from '@endolphin/theme'; +import { Button, Flex, Text } from '@endolphin/ui'; +import { Link } from '@tanstack/react-router'; + +type ErrorType = 'client' | 'server'; + +const errorText = (type: ErrorType) => { + if (type === 'client') { + return { title: '유효하지 않은 링크입니다.', description: '링크가 삭제되거나 변경되었어요.' }; + } + return { title: '언제만나 서버 오류가 발생했습니다.', description: '잠시 후 다시 시도해주세요.' }; +}; + +const ErrorPage = ({ type = 'client' }: { type?: ErrorType }) => { + const { title, description } = errorText(type); + return ( + <Flex + align='center' + direction='column' + gap={700} + height='100vh' + > + <img + alt='에러를 나타내는 이미지' + height={180} + src='/images/assets/error.webp' + width={180} + /> + <Flex + align='center' + direction='column' + gap={300} + > + <Text color={vars.color.Ref.Netural[800]} typo='h3'>{title}</Text> + <Text color={vars.color.Ref.Netural[500]} typo='b2M'>{description}</Text> + </Flex> + <Button + as={Link} + size='lg' + style='weak' + to='/' + > + 홈으로 + </Button> + </Flex> + ); +}; + +export default ErrorPage; \ No newline at end of file diff --git a/frontend/src/pages/HomePage/UpcomingSection.tsx b/frontend/apps/client/src/pages/HomePage/UpcomingSection.tsx similarity index 90% rename from frontend/src/pages/HomePage/UpcomingSection.tsx rename to frontend/apps/client/src/pages/HomePage/UpcomingSection.tsx index 14cdd5a3..e3b8e68c 100644 --- a/frontend/src/pages/HomePage/UpcomingSection.tsx +++ b/frontend/apps/client/src/pages/HomePage/UpcomingSection.tsx @@ -1,9 +1,7 @@ +import { Button, Flex, Text } from '@endolphin/ui'; import { useNavigate } from '@tanstack/react-router'; -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; import { useUpcomingQuery } from '@/features/shared-schedule/api/queries'; import UpcomingFallback from '@/features/shared-schedule/ui/Fallbacks/UpcomingFallback'; import UpcomingSchedules from '@/features/shared-schedule/ui/UpcomingSchedules'; diff --git a/frontend/src/pages/HomePage/index.css.ts b/frontend/apps/client/src/pages/HomePage/index.css.ts similarity index 100% rename from frontend/src/pages/HomePage/index.css.ts rename to frontend/apps/client/src/pages/HomePage/index.css.ts diff --git a/frontend/apps/client/src/pages/HomePage/index.tsx b/frontend/apps/client/src/pages/HomePage/index.tsx new file mode 100644 index 00000000..94ec4426 --- /dev/null +++ b/frontend/apps/client/src/pages/HomePage/index.tsx @@ -0,0 +1,27 @@ + +import { useSetAtom } from 'jotai'; +import { useEffect } from 'react'; + +import FinishedSchedules from '@/features/shared-schedule/ui/FinishedSchedules'; +import OngoingSchedules from '@/features/shared-schedule/ui/OngoingSchedules'; +import { checkboxAtom } from '@/store/discussion'; + +import { containerStyle } from './index.css'; +import UpcomingSection from './UpcomingSection'; + +const HomePage = () => { + const setCheckbox = useSetAtom(checkboxAtom); + useEffect(() => { + setCheckbox(null); + }, [setCheckbox]); + + return ( + <div className={containerStyle}> + <UpcomingSection /> + <OngoingSchedules /> + <FinishedSchedules /> + </div> + ); +}; + +export default HomePage; \ No newline at end of file diff --git a/frontend/src/pages/LandingPage/index.css.ts b/frontend/apps/client/src/pages/LandingPage/index.css.ts similarity index 87% rename from frontend/src/pages/LandingPage/index.css.ts rename to frontend/apps/client/src/pages/LandingPage/index.css.ts index 125572df..87be7d10 100644 --- a/frontend/src/pages/LandingPage/index.css.ts +++ b/frontend/apps/client/src/pages/LandingPage/index.css.ts @@ -1,8 +1,6 @@ +import { typo, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; -import { fontFamilies, fontWeights } from '@/theme/typo'; - export const containerStyle = style({ width: '100vw', height: '100vh', @@ -21,7 +19,7 @@ export const containerStyle = style({ export const titleStyle = style({ paddingTop: '8.5rem', - fontFamily: fontFamilies.pretendard, + fontFamily: typo.fontFamilies.pretendard, fontWeight: 700, fontSize: '3.25rem', textAlign: 'center', @@ -33,8 +31,8 @@ export const titleStyle = style({ }); export const subTitleStyle = style({ - fontFamily: fontFamilies.pretendard, - fontWeight: fontWeights['pretendard-0'], + fontFamily: typo.fontFamilies.pretendard, + fontWeight: typo.fontWeights['pretendard-0'], fontSize: '3.25rem', textAlign: 'center', diff --git a/frontend/src/pages/LandingPage/index.tsx b/frontend/apps/client/src/pages/LandingPage/index.tsx similarity index 86% rename from frontend/src/pages/LandingPage/index.tsx rename to frontend/apps/client/src/pages/LandingPage/index.tsx index 502d651e..71865998 100644 --- a/frontend/src/pages/LandingPage/index.tsx +++ b/frontend/apps/client/src/pages/LandingPage/index.tsx @@ -1,12 +1,8 @@ +import { vars } from '@endolphin/theme'; +import { Button, Icon, Image, Text } from '@endolphin/ui'; import { useNavigate } from '@tanstack/react-router'; -import Button from '@/components/Button'; -import { Google } from '@/components/Icon'; -import { Image } from '@/components/Image'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; - import { buttonStyle, containerStyle, @@ -57,7 +53,7 @@ const LandingPage = () => { </Text> <Button className={buttonStyle} - leftIcon={<Google />} + leftIcon={<Icon.Google />} onClick={handleClickGoogleLogin} size='xl' > diff --git a/frontend/src/pages/LoginPage/index.css.ts b/frontend/apps/client/src/pages/LoginPage/index.css.ts similarity index 100% rename from frontend/src/pages/LoginPage/index.css.ts rename to frontend/apps/client/src/pages/LoginPage/index.css.ts diff --git a/frontend/src/pages/LoginPage/index.tsx b/frontend/apps/client/src/pages/LoginPage/index.tsx similarity index 93% rename from frontend/src/pages/LoginPage/index.tsx rename to frontend/apps/client/src/pages/LoginPage/index.tsx index f8192277..498c6799 100644 --- a/frontend/src/pages/LoginPage/index.tsx +++ b/frontend/apps/client/src/pages/LoginPage/index.tsx @@ -1,4 +1,5 @@ -import { Modal } from '@/components/Modal'; +import { Modal } from '@components/Modal'; + import { GoogleLoginButton } from '@/features/login/ui/GoogleLoginButton'; import { backdropStyle } from './index.css'; diff --git a/frontend/apps/client/src/pages/MyCalendarPage/DiscussionContext.ts b/frontend/apps/client/src/pages/MyCalendarPage/DiscussionContext.ts new file mode 100644 index 00000000..65611c8c --- /dev/null +++ b/frontend/apps/client/src/pages/MyCalendarPage/DiscussionContext.ts @@ -0,0 +1,17 @@ +import { createStateContext } from '@endolphin/core/utils'; +import { type DateRangeReturn, useSelectDateRange } from '@hooks/useSelectDateRange'; +import type { Dispatch, SetStateAction } from 'react'; + +interface DiscussionContextProps { + selectedId: number | null; + setSelectedId: Dispatch<SetStateAction<number | null>>; +} + +export const { + StateProvider: DiscussionProvider, + useContextState: useDiscussionContext, +} = createStateContext< + DiscussionContextProps & DateRangeReturn, + DateRangeReturn, + DiscussionContextProps +>(useSelectDateRange); \ No newline at end of file diff --git a/frontend/apps/client/src/pages/MyCalendarPage/TableContext.ts b/frontend/apps/client/src/pages/MyCalendarPage/TableContext.ts new file mode 100644 index 00000000..0a57eabf --- /dev/null +++ b/frontend/apps/client/src/pages/MyCalendarPage/TableContext.ts @@ -0,0 +1,7 @@ +import { createStateContext } from '@endolphin/core/utils'; +import { useScrollToTime } from '@hooks/useScrollToTime'; + +export const { + StateProvider: TableProvider, + useContextState: useTableContext, +} = createStateContext(useScrollToTime); \ No newline at end of file diff --git a/frontend/src/pages/MyCalendarPage/index.css.ts b/frontend/apps/client/src/pages/MyCalendarPage/index.css.ts similarity index 91% rename from frontend/src/pages/MyCalendarPage/index.css.ts rename to frontend/apps/client/src/pages/MyCalendarPage/index.css.ts index 65dd8577..1d9ab316 100644 --- a/frontend/src/pages/MyCalendarPage/index.css.ts +++ b/frontend/apps/client/src/pages/MyCalendarPage/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: '100vw', height: '100vh', diff --git a/frontend/src/pages/MyCalendarPage/index.tsx b/frontend/apps/client/src/pages/MyCalendarPage/index.tsx similarity index 53% rename from frontend/src/pages/MyCalendarPage/index.tsx rename to frontend/apps/client/src/pages/MyCalendarPage/index.tsx index dedfb97e..04757cce 100644 --- a/frontend/src/pages/MyCalendarPage/index.tsx +++ b/frontend/apps/client/src/pages/MyCalendarPage/index.tsx @@ -1,23 +1,20 @@ +import { SharedCalendarProvider } from '@endolphin/calendar'; +import { Divider, Flex, Text } from '@endolphin/ui'; +import { useSelectDateRange } from '@hooks/useSelectDateRange'; import { useState } from 'react'; -import { SharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; -import { Divider } from '@/components/Divider'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; import { MyCalendar } from '@/features/my-calendar/ui/MyCalendar'; import SideBar from '@/features/my-calendar/ui/SideBar'; -import { useSelectDateRange } from '@/hooks/useSelectDateRange'; -import { useSharedCalendar } from '@/hooks/useSharedCalendar'; -import { DiscussionContext } from './DiscussionContext'; +import { DiscussionProvider } from './DiscussionContext'; import { containerStyle, contentStyle, titleContainerStyle, } from './index.css'; +import { TableProvider } from './TableContext'; const MyCalendarPage = () => { - const calendar = useSharedCalendar(); const [selectedId, setSelectedId] = useState<number | null>(null); return ( @@ -27,17 +24,19 @@ const MyCalendarPage = () => { </Flex> <Divider /> <Flex className={contentStyle} width='100%'> - <SharedCalendarContext.Provider value={calendar}> - <DiscussionContext.Provider value={{ + <SharedCalendarProvider> + <DiscussionProvider initialValue={{ selectedId, setSelectedId, ...useSelectDateRange(), }} > - <SideBar /> - <MyCalendar /> - </DiscussionContext.Provider> - </SharedCalendarContext.Provider> + <TableProvider> + <SideBar /> + <MyCalendar /> + </TableProvider> + </DiscussionProvider> + </SharedCalendarProvider> </Flex> </div> ); diff --git a/frontend/src/pages/UpcomingScheduleDetailPage/Header.tsx b/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/Header.tsx similarity index 55% rename from frontend/src/pages/UpcomingScheduleDetailPage/Header.tsx rename to frontend/apps/client/src/pages/UpcomingScheduleDetailPage/Header.tsx index c8e9ed21..e7417c63 100644 --- a/frontend/src/pages/UpcomingScheduleDetailPage/Header.tsx +++ b/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/Header.tsx @@ -1,9 +1,9 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { getDateParts } from '@/utils/date'; -import { formatTimeToColonString } from '@/utils/date/format'; +import { formatTimeToColonString, getDateParts } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Flex, Text } from '@endolphin/ui'; + +import { DiscussionRestartButton } from '@/features/discussion/ui/DiscussionRestartButton'; interface HeaderProps { startDateTime: Date; @@ -11,29 +11,22 @@ interface HeaderProps { } // 링크 복사 : 시간 정보를 param 으로 넘겨받아야 하는데, 나중에 url 인코딩 구현하게 되면 그때 만들면 될 듯 -const Header = ({ startDateTime, endDateTime }: HeaderProps) => - // const { handleCopyToClipboard } = useClipboard(); - ( - <Flex - align='center' - justify='space-between' - width='full' - > - <HeaderTextInfo - endDateTime={endDateTime} - startDateTime={startDateTime} - /> - <Flex align='center' gap={200}> - {/* <Button - onClick={() => handleCopyToClipboard(window.location.href)} - size='lg' - > - 링크 복사 - </Button> */} - </Flex> +const Header = ({ startDateTime, endDateTime }: HeaderProps) => ( + <Flex + align='center' + justify='space-between' + width='full' + > + <HeaderTextInfo + endDateTime={endDateTime} + startDateTime={startDateTime} + /> + <Flex align='center' gap={200}> + <DiscussionRestartButton type='upcoming' /> </Flex> - ) -; + </Flex> +) + ; const HeaderTextInfo = ({ startDateTime, endDateTime }: { startDateTime: Date; diff --git a/frontend/src/pages/UpcomingScheduleDetailPage/index.css.ts b/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/index.css.ts similarity index 100% rename from frontend/src/pages/UpcomingScheduleDetailPage/index.css.ts rename to frontend/apps/client/src/pages/UpcomingScheduleDetailPage/index.css.ts diff --git a/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/index.tsx b/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/index.tsx new file mode 100644 index 00000000..cbb473e7 --- /dev/null +++ b/frontend/apps/client/src/pages/UpcomingScheduleDetailPage/index.tsx @@ -0,0 +1,63 @@ +import { formatDateToDdayString } from '@endolphin/core/utils'; +import { Chip, Flex, Text } from '@endolphin/ui'; +import { useParams } from '@tanstack/react-router'; + +import { useUpcomingDetailsQuery } from '@/features/shared-schedule/api/queries'; +import TimelineScheduleModal from '@/features/timeline-schedule/ui'; + +import Header from './Header'; +import { backdropStyle } from './index.css'; + +const UpcomingScheduleDetailPage = () => { + const { id } = useParams({ from: '/_main/upcoming-schedule/$id' }); + + const { data, isPending } = useUpcomingDetailsQuery(id); + // TODO: 예외 처리? + if (!data || isPending) return <div className={backdropStyle} />; + + const start = data.startDateTime; + const end = data.endDateTime; + + return ( + <> + <div className={backdropStyle} /> + <TimelineScheduleModal + endDateTime={end} + isConfirmedSchedule={true} + participantWithEventsList={data.participants} + startDateTime={start} + > + <TimelineScheduleModal.TopBar> + <TopBarContent endDateTime={end} title={data.title} /> + </TimelineScheduleModal.TopBar> + <TimelineScheduleModal.Header> + <Header endDateTime={end} startDateTime={start} /> + </TimelineScheduleModal.Header> + </TimelineScheduleModal> + </> + ); +}; + +interface TopBarContentProps { + title: string; + endDateTime: Date; +} + +const TopBarContent = ({ + title, + endDateTime, +}: TopBarContentProps) => ( + <Flex align='center' gap={200}> + <Text typo='t1'>{title}</Text> + <Chip + color='coolGray' + radius='max' + size='md' + variant='weak' + > + {formatDateToDdayString(endDateTime)} + </Chip> + </Flex> +); + +export default UpcomingScheduleDetailPage; diff --git a/frontend/src/pages/UpcomingSchedulePage/index.css.ts b/frontend/apps/client/src/pages/UpcomingSchedulePage/index.css.ts similarity index 94% rename from frontend/src/pages/UpcomingSchedulePage/index.css.ts rename to frontend/apps/client/src/pages/UpcomingSchedulePage/index.css.ts index 2877888b..bac57571 100644 --- a/frontend/src/pages/UpcomingSchedulePage/index.css.ts +++ b/frontend/apps/client/src/pages/UpcomingSchedulePage/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: 1284, paddingBottom: 218, diff --git a/frontend/src/pages/UpcomingSchedulePage/index.tsx b/frontend/apps/client/src/pages/UpcomingSchedulePage/index.tsx similarity index 92% rename from frontend/src/pages/UpcomingSchedulePage/index.tsx rename to frontend/apps/client/src/pages/UpcomingSchedulePage/index.tsx index 4e643a73..1b30e613 100644 --- a/frontend/src/pages/UpcomingSchedulePage/index.tsx +++ b/frontend/apps/client/src/pages/UpcomingSchedulePage/index.tsx @@ -1,5 +1,5 @@ -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; +import { Flex, Text } from '@endolphin/ui'; + import { useUpcomingQuery } from '@/features/shared-schedule/api/queries'; import UpcomingCarousel from '@/features/shared-schedule/ui/UpcomingSchedules/UpcomingCarousel'; import UpcomingScheduleList from diff --git a/frontend/src/routes/__root.tsx b/frontend/apps/client/src/routes/__root.tsx similarity index 88% rename from frontend/src/routes/__root.tsx rename to frontend/apps/client/src/routes/__root.tsx index d0d0dcd9..5dea0957 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/apps/client/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { GlobalModal } from '@components/Modal/GlobalModal'; import type { QueryClient } from '@tanstack/react-query'; import { createRootRouteWithContext, @@ -5,8 +6,8 @@ import { Outlet } from '@tanstack/react-router'; import { lazy } from 'react'; -import { GlobalModal } from '@/components/Modal/GlobalModal'; import { defaultENV } from '@/envconfig'; +import Footer from '@/layout/Footer'; import GlobalNavBar from '@/layout/GlobalNavBar'; import ErrorPage from '@/pages/ErrorPage'; @@ -29,6 +30,7 @@ export const Route = createRootRouteWithContext<QueryClientContext>()({ <HeadContent /> <GlobalModal /> <Outlet /> + <Footer /> <TanStackRouterDevtools /> </> ), @@ -38,6 +40,7 @@ export const Route = createRootRouteWithContext<QueryClientContext>()({ <ErrorPage /> </> ), + errorComponent: () => <ErrorPage type='server' />, head: () => ({ meta: [ { diff --git a/frontend/src/routes/_main.tsx b/frontend/apps/client/src/routes/_main.tsx similarity index 87% rename from frontend/src/routes/_main.tsx rename to frontend/apps/client/src/routes/_main.tsx index c78f05b9..9ab6d5ad 100644 --- a/frontend/src/routes/_main.tsx +++ b/frontend/apps/client/src/routes/_main.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import MainLayout from '@/layout/MainLayout'; -import { isLogin } from '@/utils/auth'; +import { isLogin, refresh } from '@/utils/auth'; import { setLastRoutePath } from '@/utils/route'; const Layout = () => ( @@ -12,6 +12,7 @@ const Layout = () => ( export const Route = createFileRoute('/_main')({ beforeLoad: async ({ location }) => { + await refresh(); if (!isLogin()) { setLastRoutePath(location.pathname); throw redirect({ to: '/login' }); diff --git a/frontend/src/routes/_main/discussion/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/$id.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/$id.tsx rename to frontend/apps/client/src/routes/_main/discussion/$id.tsx diff --git a/frontend/src/routes/_main/discussion/candidate/$id.lazy.tsx b/frontend/apps/client/src/routes/_main/discussion/candidate/$id.lazy.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/candidate/$id.lazy.tsx rename to frontend/apps/client/src/routes/_main/discussion/candidate/$id.lazy.tsx diff --git a/frontend/apps/client/src/routes/_main/discussion/candidate/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/candidate/$id.tsx new file mode 100644 index 00000000..c87dc46d --- /dev/null +++ b/frontend/apps/client/src/routes/_main/discussion/candidate/$id.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, useLocation } from '@tanstack/react-router'; + +import GlobalNavBar from '@/layout/GlobalNavBar'; +import CandidateSchedulePage from '@/pages/CandidateSchedulePage'; + +const CandidateSchedule = () => { + const { state } = useLocation(); + const { candidate } = state ?? {}; + if (!candidate) return <div />; + + return ( + <> + <GlobalNavBar></GlobalNavBar> + <CandidateSchedulePage {...candidate} /> + </> + ); +}; + +export const Route = createFileRoute('/_main/discussion/candidate/$id')({ + component: CandidateSchedule, +}); diff --git a/frontend/src/routes/_main/discussion/confirm/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/confirm/$id.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/confirm/$id.tsx rename to frontend/apps/client/src/routes/_main/discussion/confirm/$id.tsx diff --git a/frontend/src/routes/_main/discussion/create/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/create/$id.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/create/$id.tsx rename to frontend/apps/client/src/routes/_main/discussion/create/$id.tsx diff --git a/frontend/src/routes/_main/discussion/create/index.tsx b/frontend/apps/client/src/routes/_main/discussion/create/index.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/create/index.tsx rename to frontend/apps/client/src/routes/_main/discussion/create/index.tsx diff --git a/frontend/src/routes/_main/discussion/edit/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/edit/$id.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/edit/$id.tsx rename to frontend/apps/client/src/routes/_main/discussion/edit/$id.tsx diff --git a/frontend/src/routes/_main/discussion/invite/$id.tsx b/frontend/apps/client/src/routes/_main/discussion/invite/$id.tsx similarity index 100% rename from frontend/src/routes/_main/discussion/invite/$id.tsx rename to frontend/apps/client/src/routes/_main/discussion/invite/$id.tsx diff --git a/frontend/src/routes/_main/home/index.tsx b/frontend/apps/client/src/routes/_main/home/index.tsx similarity index 58% rename from frontend/src/routes/_main/home/index.tsx rename to frontend/apps/client/src/routes/_main/home/index.tsx index 0f90351c..580ded3d 100644 --- a/frontend/src/routes/_main/home/index.tsx +++ b/frontend/apps/client/src/routes/_main/home/index.tsx @@ -1,6 +1,10 @@ import { createFileRoute } from '@tanstack/react-router'; import { sharedSchedulesQueryOptions } from '@/features/shared-schedule/api/queryOptions'; +import { + FINISHED_SCHEDULE_FETCH_SIZE, + ONGOING_SCHEDULE_FETCH_SIZE, +} from '@/features/shared-schedule/model'; import GlobalNavBar from '@/layout/GlobalNavBar'; import HomePage from '@/pages/HomePage'; @@ -19,8 +23,19 @@ export const Route = createFileRoute('/_main/home/')({ context: { queryClient }, }) => { queryClient.fetchQuery(sharedSchedulesQueryOptions.upcoming); - queryClient.fetchQuery(sharedSchedulesQueryOptions.ongoing(1, 6, 'ALL')); - queryClient.fetchQuery(sharedSchedulesQueryOptions.finished(1, 6, new Date().getFullYear())); + queryClient.fetchQuery( + sharedSchedulesQueryOptions.ongoing( + 1, + ONGOING_SCHEDULE_FETCH_SIZE, + 'ALL', + ), + ); + queryClient.fetchQuery( + sharedSchedulesQueryOptions.finished( + 1, + FINISHED_SCHEDULE_FETCH_SIZE, + new Date().getFullYear()), + ); }, component: Home, }); diff --git a/frontend/src/routes/_main/my-calendar/index.lazy.tsx b/frontend/apps/client/src/routes/_main/my-calendar/index.lazy.tsx similarity index 100% rename from frontend/src/routes/_main/my-calendar/index.lazy.tsx rename to frontend/apps/client/src/routes/_main/my-calendar/index.lazy.tsx diff --git a/frontend/apps/client/src/routes/_main/my-calendar/index.tsx b/frontend/apps/client/src/routes/_main/my-calendar/index.tsx new file mode 100644 index 00000000..917ad88d --- /dev/null +++ b/frontend/apps/client/src/routes/_main/my-calendar/index.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import GlobalNavBar from '@/layout/GlobalNavBar'; +import MyCalendarPage from '@/pages/MyCalendarPage'; + +const MyCalendar = () => ( + <> + <GlobalNavBar> + <GlobalNavBar.NewDiscussionLink /> + </GlobalNavBar> + <MyCalendarPage /> + </> +); + +export const Route = createFileRoute('/_main/my-calendar/')({ + component: MyCalendar, +}); diff --git a/frontend/apps/client/src/routes/_main/upcoming-schedule/$id.tsx b/frontend/apps/client/src/routes/_main/upcoming-schedule/$id.tsx new file mode 100644 index 00000000..5017d15f --- /dev/null +++ b/frontend/apps/client/src/routes/_main/upcoming-schedule/$id.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import GlobalNavBar from '@/layout/GlobalNavBar'; +import UpcomingScheduleDetailPage from '@/pages/UpcomingScheduleDetailPage'; + +const UpcomingDetail = () => ( + <> + <GlobalNavBar> + <GlobalNavBar.MyCalendarLink /> + <GlobalNavBar.NewDiscussionLink /> + </GlobalNavBar> + <UpcomingScheduleDetailPage /> + </> +); + +export const Route = createFileRoute('/_main/upcoming-schedule/$id')({ + component: UpcomingDetail, +}); + diff --git a/frontend/src/routes/_main/upcoming-schedule/index.lazy.tsx b/frontend/apps/client/src/routes/_main/upcoming-schedule/index.lazy.tsx similarity index 100% rename from frontend/src/routes/_main/upcoming-schedule/index.lazy.tsx rename to frontend/apps/client/src/routes/_main/upcoming-schedule/index.lazy.tsx diff --git a/frontend/apps/client/src/routes/_main/upcoming-schedule/index.tsx b/frontend/apps/client/src/routes/_main/upcoming-schedule/index.tsx new file mode 100644 index 00000000..fbef287a --- /dev/null +++ b/frontend/apps/client/src/routes/_main/upcoming-schedule/index.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import GlobalNavBar from '@/layout/GlobalNavBar'; +import UpcomingSchedulePage from '@/pages/UpcomingSchedulePage'; + +const UpcomingSchedule = () => ( + <> + <GlobalNavBar> + <GlobalNavBar.MyCalendarLink /> + <GlobalNavBar.NewDiscussionLink /> + </GlobalNavBar> + <UpcomingSchedulePage /> + </> +); +export const Route = createFileRoute('/_main/upcoming-schedule/')({ + component: UpcomingSchedule, +}); diff --git a/frontend/src/routes/index.tsx b/frontend/apps/client/src/routes/index.tsx similarity index 74% rename from frontend/src/routes/index.tsx rename to frontend/apps/client/src/routes/index.tsx index 2b52e32a..8b98a452 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/apps/client/src/routes/index.tsx @@ -1,9 +1,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; - -import { isLogin } from '@/utils/auth'; +import { isLogin } from '@utils/auth'; export const Route = createFileRoute('/')({ - beforeLoad: () => { + beforeLoad: async () => { throw redirect({ to: isLogin() ? '/home' : '/landing', }); diff --git a/frontend/src/routes/landing/index.tsx b/frontend/apps/client/src/routes/landing/index.tsx similarity index 100% rename from frontend/src/routes/landing/index.tsx rename to frontend/apps/client/src/routes/landing/index.tsx diff --git a/frontend/src/routes/login/index.lazy.tsx b/frontend/apps/client/src/routes/login/index.lazy.tsx similarity index 67% rename from frontend/src/routes/login/index.lazy.tsx rename to frontend/apps/client/src/routes/login/index.lazy.tsx index 7a610f30..22540ecf 100644 --- a/frontend/src/routes/login/index.lazy.tsx +++ b/frontend/apps/client/src/routes/login/index.lazy.tsx @@ -1,13 +1,9 @@ import { createLazyFileRoute } from '@tanstack/react-router'; -import GlobalNavBar from '@/layout/GlobalNavBar'; import LoginPage from '@/pages/LoginPage'; const Login = () => ( - <> - <GlobalNavBar /> - <LoginPage /> - </> + <LoginPage /> ); export const Route = createLazyFileRoute('/login/')({ diff --git a/frontend/apps/client/src/routes/login/index.tsx b/frontend/apps/client/src/routes/login/index.tsx new file mode 100644 index 00000000..1a41d49f --- /dev/null +++ b/frontend/apps/client/src/routes/login/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import LoginPage from '@/pages/LoginPage'; + +const Login = () => ( + <LoginPage /> +); + +export const Route = createFileRoute('/login/')({ + component: Login, +}); diff --git a/frontend/apps/client/src/routes/oauth.redirect/calendar/index.tsx b/frontend/apps/client/src/routes/oauth.redirect/calendar/index.tsx new file mode 100644 index 00000000..cd68906c --- /dev/null +++ b/frontend/apps/client/src/routes/oauth.redirect/calendar/index.tsx @@ -0,0 +1,23 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useEffect } from 'react'; + +import { calendarKeys } from '@/features/my-calendar/api/keys'; + +const Redirect = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + useEffect(() => { + (async () => { + await queryClient.invalidateQueries({ queryKey: calendarKeys.all }); + navigate({ to: '/my-calendar' }); + })(); + }, [queryClient, navigate]); + + return null; +}; + +export const Route = createFileRoute('/oauth/redirect/calendar/')({ + component: Redirect, +}); \ No newline at end of file diff --git a/frontend/src/routes/oauth.redirect/index.tsx b/frontend/apps/client/src/routes/oauth.redirect/login/index.tsx similarity index 87% rename from frontend/src/routes/oauth.redirect/index.tsx rename to frontend/apps/client/src/routes/oauth.redirect/login/index.tsx index 3b82b674..8e3a91b9 100644 --- a/frontend/src/routes/oauth.redirect/index.tsx +++ b/frontend/apps/client/src/routes/oauth.redirect/login/index.tsx @@ -7,7 +7,7 @@ import { getLastRoutePath } from '@/utils/route'; const Redirect = () => { const { loginMutate } = useJWTMutation(); const lastPath = getLastRoutePath(); - const params: { code: string } = useSearch({ from: '/oauth/redirect/' }); + const params: { code: string } = useSearch({ from: '/oauth/redirect/login/' }); const { code } = params; useEffect(() => { @@ -19,6 +19,6 @@ const Redirect = () => { return null; }; -export const Route = createFileRoute('/oauth/redirect/')({ +export const Route = createFileRoute('/oauth/redirect/login/')({ component: Redirect, -}); +}); \ No newline at end of file diff --git a/frontend/apps/client/src/routes/service/privacy.tsx b/frontend/apps/client/src/routes/service/privacy.tsx new file mode 100644 index 00000000..863f78e9 --- /dev/null +++ b/frontend/apps/client/src/routes/service/privacy.tsx @@ -0,0 +1,161 @@ +/* eslint-disable */ +import { createFileRoute } from '@tanstack/react-router' + +import { Flex, Text } from '@endolphin/ui'; + +const PrivacyPolicyPage = () => ( + <div> + <article> + <Text typo="h2">언제만나 서비스 개인정보 처리방침</Text> + <Flex direction="column" gap={500} justify="flex-start"> + <Text typo="b3M"> + ‘언제만나’는 정보주체의 자유와 권리 보호를 위해 「개인정보 보호법」 및 + 관계 법령이 정한 바를 준수하여, 적법하게 개인정보를 처리하고 안전하게 + 관리하고 있습니다. 이에 「개인정보 보호법」 제30조에 따라 정보주체에게 + 개인정보의 처리와 보호에 관한 절차 및 기준을 안내하고, 이와 관련한 + 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 + 개인정보 처리방침을 수립・공개합니다. + </Text> + <Text typo="b3M"> + <Text typo="t1">1. 개인정보의 처리 목적</Text> + <p> + 언제만나는 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 + 목적 이외의 용도로는 이용하지 않습니다. + </p> + <ul> + <li> + 회원 가입 및 관리: 회원제 서비스 제공에 따른 본인 식별·인증 + 목적으로 개인정보를 처리합니다. + </li> + <li> + 서비스 제공: 맞춤서비스 제공을 목적으로 개인정보를 처리합니다. + </li> + </ul> + </Text> + <Text typo="b3M"> + <Text typo="t1">2. 개인정보의 처리 및 보유 기간</Text> + <p> + ‘언제만나’는 정보주체로부터 개인정보를 수집할 때 동의 받은 개인정보 + 보유.이용기간 또는 법령에 따른 개인정보 보유.이용기간 내에서 + 개인정보를 처리.보유합니다. 구체적인 개인정보 처리 및 보유 기간은 + 다음과 같습니다. + </p> + <Text typo="b3M"> + <ul> + <li>회원가입: 구글 로그인을 통한 회원가입</li> + <li>보유 기간: 회원 탈퇴 시, 즉시 삭제</li> + </ul> + </Text> + </Text> + <Text typo="b3M"> + <Text typo="t1"> + 3. 정보주체와 법정대리인의 권리 · 의무 및 그 행사방법 이용자는 + 개인정보주체로써 다음과 같은 권리를 행사할 수 있습니다. + </Text> + <ul> + 정보주체는 ‘언제만나’에 대해 언제든지 다음 각 호의 개인정보 보호 + 관련 권리를 행사할 수 있습니다. + <li>개인정보 열람요구: 오류 등이 있을 경우 정정 요구</li> + <li>삭제요구: 처리정지 요구</li> + </ul> + </Text> + <Text typo="b3M"> + <Text typo="t1">4. 처리하는 개인정보의 항목 및 수집방법</Text> + <ul> + <li> + 언제만나를 이용하기 위해서는 Google 계정이 필요합니다. 언제만나가 + Google에서 수집하는 개인정보는 언제만나의 정상적인 작동에 필요한 + 범위로 한정됩니다.{' '} + </li> + <li>‘언제만나’는 다음의 개인정보 항목을 처리하고 있습니다.</li> + </ul> + <Text typo="t3"><구글 개인정보 제3자 제공 동의></Text> + <p> + 아래는 ‘언제만나’회원 가입 시(구글 계정을 통한 간편 가입시) 제공 + 동의를 해주시는 자동 수집 항목입니다. + </p> + <ul> + <li> + 필수항목: 프로필 정보(이름/프로필 사진), 구글 계정(이메일), 구글 + 캘린더 + </li> + </ul> + <ul> + <li> + 수집목적: 사용자 프로필 생성 및 캘린더 연동 기능 및 약속 시간 생성 + 기능에 이용 + </li> + </ul> + <ul> + <li>보유기간: 회원탈퇴 시 지체없이 파기</li> + </ul> + </Text> + <Text typo="b3M"> + <Text typo="t1">5. 개인정보의 파기</Text> + <p> + ‘언제만나’는 개인정보 처리목적이 달성된 경우에는 지체없이 해당 + 개인정보를 파기합니다.{' '} + </p> + <ul> + <li> + 파기절차: 서비스 제공을 위해 사용자에게 획득한 정보는, 탈퇴 시 + 지체 없이 삭제됩니다. + </li> + <li> + 파기방법: 전자적 파일 형태로 기록∙저장된 개인정보는 기록을 재생할 + 수 없도록 파기합니다. + </li> + </ul> + </Text> + <Text typo="b3M"> + <Text typo="t1"> + 6. 개인정보 자동으로 수집하는 장치의 설치·운영 및 그 거부에 관한 + 사항 + </Text> + <p> + ‘언제만나’는 정보주체의 이용정보를 저장하고 수시로 불러오는 + ‘쿠키(cookie)’를 사용하지 않습니다. + </p> + </Text> + <Text typo="b3M"> + <Text typo="t1">7. 개인정보 보호책임자 및 담당자 안내</Text> + <p> + ‘언제만나’는 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 + 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 + 같이 개인정보 보호책임자를 지정하고 있습니다. + </p> + <p>개인정보 보호책임자</p> + <ul> + <li>이름: 김동현</li> + <li>직책: 개발자</li> + <li>메일: efdao0931@gmail.com</li> + </ul> + </Text> + <Text typo="b3M"> + <Text typo="t1">8. 개인정보의 안전성 확보 조치</Text> + <p> + ‘언제만나’는 다음과 같이 안전성 확보에 필요한 조치를 하고 있습니다.{' '} + </p> + <ul> + <li> + ‘언제만나’는 어떤 경우에도 사용자의 Google 비밀번호를 확인하거나 + 저장하지 않습니다. 사용자가 Google 계정을 사용해 바로 로그인하면 + 최소한으로 필요한 범위에만 액세스합니다. + </li> + <li> + ‘언제만나’가 Google에 요청하는 권한은 ‘언제만나’의 정상적인 작동에 + 필요한 범위로 한정됩니다. 해당 권한은 사용자를 대신하여 데이터에 + 액세스하는 데에만 사용되며 사용자의 직접 허락 없이 열람, 편집, + 삭제되지 않습니다. + </li> + </ul> + </Text> + <Text typo="b3M">이 약관은 2025.02.25부터 시행합니다.</Text> + </Flex> + </article> + </div> +) + +export const Route = createFileRoute('/service/privacy')({ + component: PrivacyPolicyPage, +}) diff --git a/frontend/apps/client/src/scripts/generate-cert.sh b/frontend/apps/client/src/scripts/generate-cert.sh new file mode 100644 index 00000000..1e29f605 --- /dev/null +++ b/frontend/apps/client/src/scripts/generate-cert.sh @@ -0,0 +1,34 @@ +#!/bin/bash +mkdir -p mkcert +cd mkcert + +## OS 확인 +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Detected macOS" + arch=$(uname -m) + if [[ "$arch" == "arm64" ]]; then + echo "Downloading mkcert for Apple Silicon (arm64)" + curl -L -o mkcert https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-v1.4.4-darwin-arm64 + else + echo "Downloading mkcert for Intel (amd64)" + curl -L -o mkcert https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-v1.4.4-darwin-amd64 + fi + chmod +x mkcert + +elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then + echo "Detected Windows" + echo "Downloading mkcert for Windows" + curl -L -o mkcert.exe https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-v1.4.4-windows-amd64.exe + +else + echo "Unknown OS: $OSTYPE" + exit 1 +fi + +./mkcert -install +./mkcert localhost 127.0.0.1 +rm -f mkcert +mv localhost+1.pem cert.pem +mv localhost+1-key.pem key.pem + +echo "Certificate generated successfully. Be sure not to expose .pem files externally!" \ No newline at end of file diff --git a/frontend/src/scripts/git-hook-init-command.sh b/frontend/apps/client/src/scripts/git-hook-init-command.sh similarity index 100% rename from frontend/src/scripts/git-hook-init-command.sh rename to frontend/apps/client/src/scripts/git-hook-init-command.sh diff --git a/frontend/src/scripts/optimize-image.cjs b/frontend/apps/client/src/scripts/optimize-image.cjs similarity index 100% rename from frontend/src/scripts/optimize-image.cjs rename to frontend/apps/client/src/scripts/optimize-image.cjs diff --git a/frontend/apps/client/src/store/auth/index.ts b/frontend/apps/client/src/store/auth/index.ts new file mode 100644 index 00000000..cbadb61f --- /dev/null +++ b/frontend/apps/client/src/store/auth/index.ts @@ -0,0 +1,13 @@ +import { atom, getDefaultStore } from 'jotai'; + +export type AccessTokenInfo = { + accessToken: string; + expiredAt: string; +} | null; + +const jwtStore = getDefaultStore(); +const jwtAtom = atom<AccessTokenInfo | null>(null); + +export const getAccessTokenInfo = () => jwtStore.get(jwtAtom); + +export const setAccessTokenInfo = (props: AccessTokenInfo) => jwtStore.set(jwtAtom, props); diff --git a/frontend/src/store/discussion/index.ts b/frontend/apps/client/src/store/discussion/index.ts similarity index 100% rename from frontend/src/store/discussion/index.ts rename to frontend/apps/client/src/store/discussion/index.ts diff --git a/frontend/src/store/global/modal.ts b/frontend/apps/client/src/store/global/modal.ts similarity index 88% rename from frontend/src/store/global/modal.ts rename to frontend/apps/client/src/store/global/modal.ts index 72be3252..f042c740 100644 --- a/frontend/src/store/global/modal.ts +++ b/frontend/apps/client/src/store/global/modal.ts @@ -1,7 +1,6 @@ +import type { ModalInfo, ModalWithoutIsOpen } from '@hooks/useModal'; import { atom, useAtom } from 'jotai'; -import type { ModalInfo, ModalWithoutIsOpen } from '@/hooks/useModal'; - export const openAtom = atom(false); export const modalAtom = atom<ModalWithoutIsOpen | null>(null); diff --git a/frontend/src/store/global/notification.ts b/frontend/apps/client/src/store/global/notification.ts similarity index 80% rename from frontend/src/store/global/notification.ts rename to frontend/apps/client/src/store/global/notification.ts index e29e9f1a..2005c37a 100644 --- a/frontend/src/store/global/notification.ts +++ b/frontend/apps/client/src/store/global/notification.ts @@ -1,8 +1,7 @@ +import type { NotificationProps } from '@components/Notification'; import type { PrimitiveAtom } from 'jotai'; import { atom, getDefaultStore } from 'jotai'; -import type { NotificationProps } from '@/components/Notification'; - export const notiStore = getDefaultStore(); export type NotiAtom = PrimitiveAtom<NotificationProps>; @@ -17,4 +16,8 @@ export const addNoti = (props: NotificationProps) => { }, 3000); }; +export const closeAllNoti = () => { + notiStore.set(notiAtomsAtom, []); +}; + export const getNotifications = () => notiStore.get(notiAtomsAtom); \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/apps/client/src/types/api.ts similarity index 100% rename from frontend/src/types/api.ts rename to frontend/apps/client/src/types/api.ts diff --git a/frontend/apps/client/src/types/defaultProps.ts b/frontend/apps/client/src/types/defaultProps.ts new file mode 100644 index 00000000..1a46452a --- /dev/null +++ b/frontend/apps/client/src/types/defaultProps.ts @@ -0,0 +1,6 @@ +import type { CSSProperties, PropsWithChildren } from 'react'; + +export interface DefaultProps extends PropsWithChildren { + className?: string; + style?: CSSProperties; +} \ No newline at end of file diff --git a/frontend/apps/client/src/utils/auth/accessTokenService.ts b/frontend/apps/client/src/utils/auth/accessTokenService.ts new file mode 100644 index 00000000..12d3c1d2 --- /dev/null +++ b/frontend/apps/client/src/utils/auth/accessTokenService.ts @@ -0,0 +1,67 @@ +import { loginApi } from '@/features/login/api'; +import type { JWTResponse } from '@/features/login/model'; +import { getAccessTokenInfo, setAccessTokenInfo } from '@/store/auth'; + +// lock +let refreshPromise: Promise<JWTResponse> | null = null; + +export const accessTokenService = { + setAccessToken: ({ accessToken, expiredAt }: JWTResponse) => { + setAccessTokenInfo({ accessToken, expiredAt }); + }, + getAccessToken: () => { + const accessTokenInfo = getAccessTokenInfo(); + if (!accessTokenInfo) return null; + + const { accessToken } = accessTokenInfo; + + return accessToken; + }, + clearAccessToken: () => { + setAccessTokenInfo(null); + }, + isJwtAvaliable: () => { + const accessTokenInfo = getAccessTokenInfo(); + if (!accessTokenInfo) return false; + + const { expiredAt } = accessTokenInfo; + return !isJwtExpired(expiredAt); + }, + getAccessTokenWithRenewal: async () => { + if (!accessTokenService.isJwtAvaliable()) { + await accessTokenService.renewAccessToken(); + const token = accessTokenService.getAccessToken(); + return token; + }; + + const tokenInfo = getAccessTokenInfo(); + if (!tokenInfo) return null; + + const { accessToken } = tokenInfo; + return accessToken; + }, + renewAccessToken: async () => { + if (refreshPromise) { + return refreshPromise; + } + + const localPromise = loginApi.renewJWT(); + refreshPromise = localPromise; + + try { + const jwtResponse = await localPromise; + accessTokenService.setAccessToken(jwtResponse); + return true; + } catch { + accessTokenService.clearAccessToken(); + return false; + } finally { + // race condition 방지를 위해, 자신이 생성한 promise가 원본 Promise인지 확인 후 null로 초기화 + if (refreshPromise === localPromise) { + refreshPromise = null; + } + } + }, +}; + +const isJwtExpired = (expiredAt: string) => new Date(expiredAt).getTime() < Date.now(); diff --git a/frontend/apps/client/src/utils/auth/index.ts b/frontend/apps/client/src/utils/auth/index.ts new file mode 100644 index 00000000..5d3deae7 --- /dev/null +++ b/frontend/apps/client/src/utils/auth/index.ts @@ -0,0 +1,16 @@ +import { loginApi } from '@/features/login/api'; + +import { accessTokenService } from './accessTokenService'; + +export const refresh = async () => { + const accessToken = await accessTokenService.getAccessTokenWithRenewal(); + if (!accessToken) return false; + return true; +}; + +export const isLogin = () => accessTokenService.isJwtAvaliable(); + +export const logout = () => { + loginApi.removeRefreshToken(); + accessTokenService.clearAccessToken(); +}; diff --git a/frontend/apps/client/src/utils/error/Errorboundary.tsx b/frontend/apps/client/src/utils/error/Errorboundary.tsx new file mode 100644 index 00000000..ea0b85a6 --- /dev/null +++ b/frontend/apps/client/src/utils/error/Errorboundary.tsx @@ -0,0 +1,101 @@ +import { isArrayDifferent } from '@endolphin/core/utils'; +import type { + ComponentPropsWithRef, + ErrorInfo, + PropsWithChildren, + ReactNode, + RefObject, +} from 'react'; +import { + Component, + useImperativeHandle, + useRef, +} from 'react'; + +type RenderFallbackProps<ErrorType extends Error = Error> = { + error: ErrorType; + reset: () => void; +}; + +type RenderFallbackType + = <ErrorType extends Error>(props: RenderFallbackProps<ErrorType>) => ReactNode; +type IgnoreErrorType = <ErrorType extends Error = Error>(error: ErrorType) => boolean; + +type Props<ErrorType extends Error = Error> = { + resetKeys?: unknown[]; + onReset?(): void; + renderFallback: RenderFallbackType; + onError?(error: ErrorType, info: ErrorInfo): void; + ignoreError?: IgnoreErrorType; +}; + +interface State<ErrorType extends Error = Error> { + error: ErrorType | null; +} + +const initialState: State = { + error: null, +}; + +class BaseErrorBoundary extends Component<PropsWithChildren<Props>, State> { + state = initialState; + + static getDerivedStateFromError (error: Error) { + return { error }; + } + + componentDidCatch (error: Error, info: ErrorInfo) { + const { onError, ignoreError } = this.props; + + if (ignoreError?.(error)) throw error; + onError?.(error, info); + } + + resetErrorBoundary = () => { + this.props.onReset?.(); + this.setState(initialState); + }; + + componentDidUpdate (prevProps: Props) { + if (this.state.error == null) return; + + if (isArrayDifferent(prevProps.resetKeys, this.props.resetKeys)) { + this.resetErrorBoundary(); + } + } + + render () { + const { children, renderFallback } = this.props; + const { error } = this.state; + + if (error != null) { + return renderFallback({ + error, + reset: this.resetErrorBoundary, + }); + } + + return children; + } +} + +interface ErrorBoundaryType extends ComponentPropsWithRef<typeof BaseErrorBoundary> { + resetRef?: RefObject<{ reset: () => void }>; +} + +export const ErrorBoundary = (props: ErrorBoundaryType) => { + const resetKeys = [...(props.resetKeys || [])]; + + const ref = useRef<BaseErrorBoundary>(null); + useImperativeHandle(props.resetRef, () => ({ + reset: () => ref.current?.resetErrorBoundary(), + })); + + return ( + <BaseErrorBoundary + {...props} + ref={ref} + resetKeys={resetKeys} + /> + ); +}; \ No newline at end of file diff --git a/frontend/apps/client/src/utils/error/HTTPError.ts b/frontend/apps/client/src/utils/error/HTTPError.ts new file mode 100644 index 00000000..d9abc2c7 --- /dev/null +++ b/frontend/apps/client/src/utils/error/HTTPError.ts @@ -0,0 +1,25 @@ +import type { ErrorCode } from '@constants/error'; +import { errorMessages } from '@constants/error'; + +export interface HTTPErrorProps { + code: ErrorCode; + message: string; +} + +export class HTTPError extends Error { + #status: number; + #code: ErrorCode; + + constructor ({ status, code, message }: { status: number } & HTTPErrorProps) { + super(errorMessages[code] || message); + this.name = 'HTTPError'; + this.#status = status; + this.#code = code; + } + + isUnAuthorizedError = () => this.#status === 401; + isForbiddenError = () => this.#status === 403; + isTooManyRequestsError = () => this.#status === 429; + + isInvalidRefreshTokenError = () => this.#code.startsWith('RT'); +} diff --git a/frontend/apps/client/src/utils/error/NetworkError.ts b/frontend/apps/client/src/utils/error/NetworkError.ts new file mode 100644 index 00000000..e0972111 --- /dev/null +++ b/frontend/apps/client/src/utils/error/NetworkError.ts @@ -0,0 +1,8 @@ +import { NETWORK_ERROR_MESSAGE } from '@constants/error'; + +export class NetworkError extends Error { + constructor () { + super(NETWORK_ERROR_MESSAGE); + this.name = 'NetworkError'; + } +} diff --git a/frontend/apps/client/src/utils/error/handleError.ts b/frontend/apps/client/src/utils/error/handleError.ts new file mode 100644 index 00000000..9ee665ff --- /dev/null +++ b/frontend/apps/client/src/utils/error/handleError.ts @@ -0,0 +1,42 @@ +import { DEFAULT_ERROR_MESSAGE } from '@constants/error'; +import { HTTPError, NetworkError } from '@utils/error'; +import { ZodError } from 'zod'; + +import { addNoti, closeAllNoti } from '@/store/global/notification'; + +export const handleError = (error: unknown) => { + if (error instanceof ZodError) { + // 개발자 디버깅용 콘솔 출력 + // eslint-disable-next-line no-console + console.error(error); + return false; + } + + queueMicrotask(() => { + closeAllNoti(); + }); + + if (error instanceof NetworkError) return true; + + if (error instanceof HTTPError) { + queueMicrotask(() => { + addNoti({ type: 'error', title: error.message }); + }); + + if (error.isUnAuthorizedError()) window.location.href = '/login'; + if (error.isForbiddenError()) window.location.href = '/landing'; + return false; + } + + if (error instanceof Error) { + queueMicrotask(() => { + addNoti({ type: 'error', title: error.message }); + }); + return false; + } + + queueMicrotask(() => { + addNoti({ type: 'error', title: DEFAULT_ERROR_MESSAGE }); + }); + return false; +}; diff --git a/frontend/apps/client/src/utils/error/index.ts b/frontend/apps/client/src/utils/error/index.ts new file mode 100644 index 00000000..051a100d --- /dev/null +++ b/frontend/apps/client/src/utils/error/index.ts @@ -0,0 +1,2 @@ +export * from './HTTPError'; +export * from './NetworkError'; \ No newline at end of file diff --git a/frontend/src/utils/fetch/index.ts b/frontend/apps/client/src/utils/fetch/executeFetch.ts similarity index 54% rename from frontend/src/utils/fetch/index.ts rename to frontend/apps/client/src/utils/fetch/executeFetch.ts index 5e608a25..03ae2efe 100644 --- a/frontend/src/utils/fetch/index.ts +++ b/frontend/apps/client/src/utils/fetch/executeFetch.ts @@ -2,43 +2,24 @@ import { serviceENV } from '@/envconfig'; -import { getAccessToken } from '../auth'; +import { accessTokenService } from '../auth/accessTokenService'; import type { HTTPErrorProps } from '../error'; import { HTTPError } from '../error'; -type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +export type FetchMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export type RequestOptions = { headers?: HeadersInit; }; -const buildFetchOptions = (options?: RequestInit): RequestInit => { - const accessToken = getAccessToken(); - const defaultHeaders = { - 'Content-Type': 'application/json', - ...(accessToken && { Authorization: `Bearer ${accessToken}` }), - }; - - const headers = { ...defaultHeaders, ...options?.headers }; - - const defaultOptions: RequestInit = { - headers, - mode: 'cors', - // accessToken을 쿠키로 관리하기 위한 설정 - credentials: 'include', - }; - - return { ...defaultOptions, ...options, headers }; -}; - -interface FetchRequest { +export interface FetchRequest { params?: Record<string, string>; body?: Record<string, unknown>; options?: RequestOptions; } export const executeFetch = async ( - method: Method, + method: FetchMethod, endpoint: string, { params, body, options }: FetchRequest = {}, ) => { @@ -47,7 +28,7 @@ export const executeFetch = async ( ? `?${new URLSearchParams(params).toString()}` : ''; const fullUrl = `${serviceENV.BASE_URL}${endpoint}${queryString}`; - + try { const response = await fetch(fullUrl, { method: method, @@ -78,30 +59,20 @@ export const executeFetch = async ( } }; -/** - * HTTP 요청을 수행하기 위한 헬퍼 메서드를 제공합니다. - * - * 이 객체는 일반적인 API 작업을 수행하기 위해 HTTP 메서드를 캡슐화합니다. - * GET, POST, PUT, DELETE 요청을 헬퍼 함수로 지원하며, - * 각 메서드는 프로미스를 반환합니다. - * - * @example - * const data = await request.get<MyDataType>('https://api.example.com/data'); - * - * @property get - 지정된 엔드포인트로 HTTP GET 요청을 보냅니다. - * @property post - 지정된 엔드포인트로 선택적 본문과 함께 HTTP POST 요청을 보냅니다. - * @property put - 지정된 엔드포인트로 선택적 본문과 함께 HTTP PUT 요청을 보냅니다. - * @property delete - 지정된 엔드포인트로 HTTP DELETE 요청을 보냅니다. - */ -export const request = { - get: (endpoint: string, props?: Pick<FetchRequest, 'params'>) => - executeFetch('GET', endpoint, props), - post: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => - executeFetch('POST', endpoint, props), - put: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => - executeFetch('PUT', endpoint, props), - patch: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => - executeFetch('PATCH', endpoint, props), - delete: (endpoint: string, props?: Pick<FetchRequest, 'params'>) => - executeFetch('DELETE', endpoint, props), +const buildFetchOptions = (options?: RequestInit): RequestInit => { + const accessToken = accessTokenService.getAccessToken(); + const defaultHeaders = { + 'Content-Type': 'application/json', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), + }; + + const headers = { ...defaultHeaders, ...options?.headers }; + + const defaultOptions: RequestInit = { + headers, + mode: 'cors', + credentials: 'include', + }; + + return { ...defaultOptions, ...options, headers }; }; diff --git a/frontend/apps/client/src/utils/fetch/index.ts b/frontend/apps/client/src/utils/fetch/index.ts new file mode 100644 index 00000000..73739f05 --- /dev/null +++ b/frontend/apps/client/src/utils/fetch/index.ts @@ -0,0 +1,30 @@ +import type { FetchRequest } from './executeFetch'; +import { fetchWithInterceptor } from './interceptor'; + +/** + * HTTP 요청을 수행하기 위한 헬퍼 메서드를 제공합니다. + * + * 이 객체는 일반적인 API 작업을 수행하기 위해 HTTP 메서드를 캡슐화합니다. + * GET, POST, PUT, DELETE 요청을 헬퍼 함수로 지원하며, + * 각 메서드는 프로미스를 반환합니다. + * + * @example + * const data = await request.get<MyDataType>('https://api.example.com/data'); + * + * @property get - 지정된 엔드포인트로 HTTP GET 요청을 보냅니다. + * @property post - 지정된 엔드포인트로 선택적 본문과 함께 HTTP POST 요청을 보냅니다. + * @property put - 지정된 엔드포인트로 선택적 본문과 함께 HTTP PUT 요청을 보냅니다. + * @property delete - 지정된 엔드포인트로 HTTP DELETE 요청을 보냅니다. + */ +export const request = { + get: (endpoint: string, props?: Pick<FetchRequest, 'params'>) => + fetchWithInterceptor('GET', endpoint, props), + post: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => + fetchWithInterceptor('POST', endpoint, props), + put: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => + fetchWithInterceptor('PUT', endpoint, props), + patch: (endpoint: string, props?: Pick<FetchRequest, 'body'>) => + fetchWithInterceptor('PATCH', endpoint, props), + delete: (endpoint: string, props?: Pick<FetchRequest, 'params'>) => + fetchWithInterceptor('DELETE', endpoint, props), +}; diff --git a/frontend/apps/client/src/utils/fetch/interceptor/index.ts b/frontend/apps/client/src/utils/fetch/interceptor/index.ts new file mode 100644 index 00000000..3f73c1ad --- /dev/null +++ b/frontend/apps/client/src/utils/fetch/interceptor/index.ts @@ -0,0 +1,14 @@ +import type { FetchMethod, FetchRequest } from '../executeFetch'; +import { executeFetch } from '../executeFetch'; +import { interceptResponse } from './interceptResponse'; + +export const fetchWithInterceptor = async ( + method: FetchMethod, + endpoint: string, + fetchRequest?: Partial<FetchRequest>, +) => { + const originFetch = () => executeFetch(method, endpoint, fetchRequest); + const responsePromise = executeFetch(method, endpoint, fetchRequest); + + return interceptResponse(responsePromise, originFetch) as typeof responsePromise; +}; diff --git a/frontend/apps/client/src/utils/fetch/interceptor/interceptResponse.ts b/frontend/apps/client/src/utils/fetch/interceptor/interceptResponse.ts new file mode 100644 index 00000000..ec55e95d --- /dev/null +++ b/frontend/apps/client/src/utils/fetch/interceptor/interceptResponse.ts @@ -0,0 +1,25 @@ +import type { HTTPError } from '@utils/error'; + +import { renewJwtHandler } from './renewJwtHandler'; + +export interface InterceptorHandler { + onFulfilled: ( + response: Response, + originFetch: () => Promise<Response>, + ) => Response | Promise<Response>; + onRejected: ( + error: HTTPError, + originFetch: () => Promise<Response>, + ) => Response | Promise<Response>; +} + +const handlers: InterceptorHandler[] = [renewJwtHandler]; + +export const interceptResponse = ( + responsePromise: Promise<Response>, + originFetch: () => Promise<Response>, +) => + handlers.reduce((prevPromise, handler) => prevPromise.then( + (res) => handler.onFulfilled(res, originFetch), + (err) => handler.onRejected(err, originFetch), + ), responsePromise); diff --git a/frontend/apps/client/src/utils/fetch/interceptor/renewJwtHandler.ts b/frontend/apps/client/src/utils/fetch/interceptor/renewJwtHandler.ts new file mode 100644 index 00000000..cae3a223 --- /dev/null +++ b/frontend/apps/client/src/utils/fetch/interceptor/renewJwtHandler.ts @@ -0,0 +1,16 @@ + +import { accessTokenService } from '@utils/auth/accessTokenService'; + +import type { InterceptorHandler } from './interceptResponse'; + +export const renewJwtHandler: InterceptorHandler = { + onFulfilled: (response) => response, + onRejected: async (error, originFetch) => { + if (error.isInvalidRefreshTokenError()) throw error; + if (error.isUnAuthorizedError()) { + await accessTokenService.renewAccessToken(); + return originFetch(); + } + throw error; + }, +}; diff --git a/frontend/src/utils/route/index.ts b/frontend/apps/client/src/utils/route/index.ts similarity index 100% rename from frontend/src/utils/route/index.ts rename to frontend/apps/client/src/utils/route/index.ts diff --git a/frontend/src/utils/zod/index.ts b/frontend/apps/client/src/utils/zod/index.ts similarity index 79% rename from frontend/src/utils/zod/index.ts rename to frontend/apps/client/src/utils/zod/index.ts index d3acc5b2..6349bf78 100644 --- a/frontend/src/utils/zod/index.ts +++ b/frontend/apps/client/src/utils/zod/index.ts @@ -1,9 +1,7 @@ +import { DATE_BAR, TIME } from '@constants/regex'; +import { parseTime } from '@endolphin/core/utils'; import { z } from 'zod'; -import { DATE_BAR, TIME } from '@/constants/regex'; - -import { parseTime } from '../date'; - export const zDate = z.string().regex(DATE_BAR) .transform((v) => new Date(v)); diff --git a/frontend/src/vite-env.d.ts b/frontend/apps/client/src/vite-env.d.ts similarity index 100% rename from frontend/src/vite-env.d.ts rename to frontend/apps/client/src/vite-env.d.ts diff --git a/frontend/apps/client/tsconfig.app.json b/frontend/apps/client/tsconfig.app.json new file mode 100644 index 00000000..399d9658 --- /dev/null +++ b/frontend/apps/client/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "types": ["vitest/globals", "@vitest/browser/providers/playwright"], + "paths": { + "@/*": ["src/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@constants/*": ["src/constants/*"], + "@components/*": ["src/components/*"], + "@features/*": ["src/features/*"], + }, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "allowImportingTsExtensions": true, + "noEmit": true, + }, + "include": ["src", "__tests__", "../../packages/ui/scripts/create-icon.cjs", "../../packages/theme/scripts/create-theme.cjs"] +} diff --git a/frontend/apps/client/tsconfig.json b/frontend/apps/client/tsconfig.json new file mode 100644 index 00000000..53755c5c --- /dev/null +++ b/frontend/apps/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + ] +} \ No newline at end of file diff --git a/frontend/apps/client/tsconfig.node.json b/frontend/apps/client/tsconfig.node.json new file mode 100644 index 00000000..d5c16e5b --- /dev/null +++ b/frontend/apps/client/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "lib": ["ES2023"], + + "allowImportingTsExtensions": true, + "noEmit": true, + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/apps/client/vite.config.ts b/frontend/apps/client/vite.config.ts new file mode 100644 index 00000000..f67dc845 --- /dev/null +++ b/frontend/apps/client/vite.config.ts @@ -0,0 +1,47 @@ +import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; +import { vanillaExtractPlugin as veEsbuildPlugin } from '@vanilla-extract/esbuild-plugin'; +import { vanillaExtractPlugin as veVitePlugin } from '@vanilla-extract/vite-plugin'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +// https://vite.dev/config/ +export default defineConfig({ + build: { + target: 'esnext', + outDir: 'dist', + }, + plugins: [ + TanStackRouterVite({ + autoCodeSplitting: true, + routeFileIgnorePrefix: '@', + routesDirectory: path.resolve(dirname, 'src/routes'), + generatedRouteTree: path.resolve(dirname, 'src/routeTree.gen.ts'), + }), + react(), + veVitePlugin(), + ], + optimizeDeps: { + esbuildOptions: { + plugins: [veEsbuildPlugin({ runtime: true })], + }, + }, + resolve: { + alias: { + '@': path.resolve(dirname, 'src'), + '@hooks': path.resolve(dirname, 'src/hooks'), + '@utils': path.resolve(dirname, 'src/utils'), + '@constants': path.resolve(dirname, 'src/constants'), + '@components': path.resolve(dirname, 'src/components'), + '@features': path.resolve(dirname, 'src/features'), + }, + }, + ssr: { + noExternal: ['@endolphin/theme', '@endolphin/ui'], + }, + envDir: path.resolve(dirname, '../../'), +}); diff --git a/frontend/apps/client/vitest.config.ts b/frontend/apps/client/vitest.config.ts new file mode 100644 index 00000000..d5a4d5be --- /dev/null +++ b/frontend/apps/client/vitest.config.ts @@ -0,0 +1,24 @@ +import { createAlias, reactConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + reactConfig, + defineProject({ + root: dirname, + test: { + include: ['__tests__/unit/**/*.ts?(x)'], + setupFiles: ['./setup-file.ts'], + }, + resolve: { + alias: { + ...createAlias(dirname), + '@features': path.resolve(dirname, 'src/features'), + }, + }, + }), +); \ No newline at end of file diff --git a/frontend/apps/server/package.json b/frontend/apps/server/package.json new file mode 100644 index 00000000..b19afd39 --- /dev/null +++ b/frontend/apps/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "@endolphin/server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "build": "cross-env NODE_ENV=build tsup", + "start": "cross-env NODE_ENV=start tsup --watch" + }, + "dependencies": { + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.2", + "typescript": "^5.6.2", + "@endolphin/tsup-config": "workspace:^", + "@endolphin/vitest-config": "workspace:^" + } +} diff --git a/frontend/server/cookies.js b/frontend/apps/server/src/cookies.ts similarity index 74% rename from frontend/server/cookies.js rename to frontend/apps/server/src/cookies.ts index 9fed174e..a018f204 100644 --- a/frontend/server/cookies.js +++ b/frontend/apps/server/src/cookies.ts @@ -3,9 +3,9 @@ export const cookies = (cookieHeader = '') => { const [key, value] = cookie.split('='); acc[key] = decodeURIComponent(value); return acc; - }, {}); + }, {} as Record<string, string>); - const get = (key) => cookieStore[key]; + const get = (key: string) => cookieStore[key]; return { get }; }; \ No newline at end of file diff --git a/frontend/apps/server/src/index.ts b/frontend/apps/server/src/index.ts new file mode 100644 index 00000000..cdf61840 --- /dev/null +++ b/frontend/apps/server/src/index.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import express from 'express'; +import type { ViteDevServer } from 'vite'; + +const isProduction = process.env.NODE_ENV === 'production'; +const port = process.env.PORT || 5173; +const base = process.env.BASE || '/'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const clientPath = isProduction ? '../client' : '../../client'; +const keypath = path.resolve(dirname, clientPath, 'mkcert/key.pem'); +const certpath = path.resolve(dirname, clientPath, 'mkcert/cert.pem'); + +const app = express(); + +let vite: ViteDevServer; + +const addMiddleware = async () => { + if (!isProduction) { + const { createServer } = await import('vite'); + vite = await createServer({ + root: path.resolve(dirname, clientPath), + server: { + middlewareMode: true, + https: { + key: keypath, + cert: certpath, + }, + }, + appType: 'custom', + base, + }); + app.use(vite.middlewares); + } else { + app.use(express.static(path.resolve(dirname, clientPath))); + } +}; + +const serveHTML = async () => { + app.use(/(.*)/, async (req, res) => { + try { + const url = req.originalUrl.replace(base, ''); + const templateHtml = fs.readFileSync( + path.resolve(dirname, clientPath, 'index.html'), + 'utf-8', + ); + + let template: string; + if (!isProduction) { + template = await vite.transformIndexHtml(url, templateHtml); + } else { + template = templateHtml; + } + + res.status(200).set({ 'Content-Type': 'text/html' }) + .send(template); + } catch (e: unknown) { + if (e instanceof Error) { + vite?.ssrFixStacktrace(e); + res.status(500).end(e.stack); + } + } + }); +}; + +const createServer = async () => { + await addMiddleware(); + await serveHTML(); + + if (!isProduction) { + const httpsOptions = { + key: fs.readFileSync(keypath), + cert: fs.readFileSync(certpath), + }; + + https.createServer(httpsOptions, app).listen(port, () => { + // eslint-disable-next-line no-console + console.log( + `\n🔐 Dev HTTPS server running at \x1b[33mhttps://localhost:${port}\x1b[0m \n`, + ); + }); + } else { + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log( + `\n🚀 Prod HTTP server running at \x1b[33mhttp://localhost:${port}\x1b[0m \n`, + ); + }); + } +}; + +createServer(); diff --git a/frontend/apps/server/tsconfig.json b/frontend/apps/server/tsconfig.json new file mode 100644 index 00000000..3e1b3a53 --- /dev/null +++ b/frontend/apps/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "incremental": true + }, + "include": ["src", "*.config.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/frontend/apps/server/tsup.config.ts b/frontend/apps/server/tsup.config.ts new file mode 100644 index 00000000..6ad88fea --- /dev/null +++ b/frontend/apps/server/tsup.config.ts @@ -0,0 +1,13 @@ +import process from 'node:process'; + +import { nodeConfig } from '@endolphin/tsup-config'; +import { defineConfig } from 'tsup'; + +const isStart = process.env.NODE_ENV === 'start'; + +export default defineConfig({ + ...nodeConfig, + dts: false, + bundle: false, + onSuccess: isStart ? 'node dist/index.js' : undefined, +}); diff --git a/frontend/apps/server/vitest.config.ts b/frontend/apps/server/vitest.config.ts new file mode 100644 index 00000000..4cdc3198 --- /dev/null +++ b/frontend/apps/server/vitest.config.ts @@ -0,0 +1,14 @@ +import { nodeConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + nodeConfig, + defineProject({ + root: dirname, + }), +); \ No newline at end of file diff --git a/frontend/configs/tsup-config/package.json b/frontend/configs/tsup-config/package.json new file mode 100644 index 00000000..30a8b942 --- /dev/null +++ b/frontend/configs/tsup-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@endolphin/tsup-config", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/types/index.d.ts", + "files": ["dist"], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + } +} \ No newline at end of file diff --git a/frontend/configs/tsup-config/src/index.ts b/frontend/configs/tsup-config/src/index.ts new file mode 100644 index 00000000..37c4742c --- /dev/null +++ b/frontend/configs/tsup-config/src/index.ts @@ -0,0 +1,2 @@ +export * from './node.js'; +export * from './react.js'; \ No newline at end of file diff --git a/frontend/configs/tsup-config/src/node.ts b/frontend/configs/tsup-config/src/node.ts new file mode 100644 index 00000000..e8143680 --- /dev/null +++ b/frontend/configs/tsup-config/src/node.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export const nodeConfig = defineConfig({ + format: ['esm'], + entry: ['src/index.ts'], + outDir: 'dist', + dts: true, + clean: true, + tsconfig: './tsconfig.json', +}); diff --git a/frontend/configs/tsup-config/src/react.ts b/frontend/configs/tsup-config/src/react.ts new file mode 100644 index 00000000..a9bb2e9e --- /dev/null +++ b/frontend/configs/tsup-config/src/react.ts @@ -0,0 +1,13 @@ +import { vanillaExtractPlugin } from '@vanilla-extract/esbuild-plugin'; +import { defineConfig } from 'tsup'; + +export const reactConfig = defineConfig({ + format: ['esm'], + entry: ['src/index.ts'], + outDir: 'dist', + esbuildPlugins: [vanillaExtractPlugin()], + dts: true, + clean: true, + tsconfig: './tsconfig.json', + external: ['react', 'react-dom'], +}); diff --git a/frontend/configs/tsup-config/tsconfig.json b/frontend/configs/tsup-config/tsconfig.json new file mode 100644 index 00000000..eb7c9c96 --- /dev/null +++ b/frontend/configs/tsup-config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationDir": "dist/types", + }, + "include": ["src"], +} \ No newline at end of file diff --git a/frontend/configs/vitest-config/package.json b/frontend/configs/vitest-config/package.json new file mode 100644 index 00000000..de8d2f10 --- /dev/null +++ b/frontend/configs/vitest-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@endolphin/vitest-config", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/types/index.d.ts", + "files": ["dist"], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + } +} diff --git a/frontend/configs/vitest-config/src/browser.ts b/frontend/configs/vitest-config/src/browser.ts new file mode 100644 index 00000000..28575f04 --- /dev/null +++ b/frontend/configs/vitest-config/src/browser.ts @@ -0,0 +1,18 @@ +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export const browserConfig = defineConfig({ + plugins: [react(), vanillaExtractPlugin()], + test: { + globals: true, + environment: 'jsdom', + passWithNoTests: true, + browser: { + viewport: { height: 1080, width: 1920 }, + provider: 'playwright', + enabled: true, + instances: [{ browser: 'chromium' }], + }, + }, +}); \ No newline at end of file diff --git a/frontend/configs/vitest-config/src/createAlias.ts b/frontend/configs/vitest-config/src/createAlias.ts new file mode 100644 index 00000000..121faaee --- /dev/null +++ b/frontend/configs/vitest-config/src/createAlias.ts @@ -0,0 +1,14 @@ +import path from 'path'; + +/** + * + * @param {string} dirname - 현재 패키지의 루트 디렉토리. + * @returns + */ +export const createAlias = (dirname: string) => ({ + '@': path.resolve(dirname, 'src'), + '@hooks': path.resolve(dirname, 'src/hooks'), + '@utils': path.resolve(dirname, 'src/utils'), + '@constants': path.resolve(dirname, 'src/constants'), + '@components': path.resolve(dirname, 'src/components'), +}); \ No newline at end of file diff --git a/frontend/configs/vitest-config/src/index.ts b/frontend/configs/vitest-config/src/index.ts new file mode 100644 index 00000000..deb86f41 --- /dev/null +++ b/frontend/configs/vitest-config/src/index.ts @@ -0,0 +1,4 @@ +export * from './browser.js'; +export * from './createAlias.js'; +export * from './node.js'; +export * from './react.js'; \ No newline at end of file diff --git a/frontend/configs/vitest-config/src/node.ts b/frontend/configs/vitest-config/src/node.ts new file mode 100644 index 00000000..cb8a4cea --- /dev/null +++ b/frontend/configs/vitest-config/src/node.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export const nodeConfig = defineConfig({ + test: { + globals: true, + environment: 'node', + passWithNoTests: true, + }, +}); \ No newline at end of file diff --git a/frontend/configs/vitest-config/src/react.ts b/frontend/configs/vitest-config/src/react.ts new file mode 100644 index 00000000..3957ba96 --- /dev/null +++ b/frontend/configs/vitest-config/src/react.ts @@ -0,0 +1,12 @@ +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export const reactConfig = defineConfig({ + plugins: [react(), vanillaExtractPlugin()], + test: { + globals: true, + environment: 'jsdom', + passWithNoTests: true, + }, +}); \ No newline at end of file diff --git a/frontend/configs/vitest-config/tsconfig.json b/frontend/configs/vitest-config/tsconfig.json new file mode 100644 index 00000000..eb7c9c96 --- /dev/null +++ b/frontend/configs/vitest-config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationDir": "dist/types", + }, + "include": ["src"], +} \ No newline at end of file diff --git a/frontend/endolphin.code-workspace b/frontend/endolphin.code-workspace new file mode 100644 index 00000000..2673c638 --- /dev/null +++ b/frontend/endolphin.code-workspace @@ -0,0 +1,60 @@ +{ + "folders": [ + { + "name": "frontend", + "path": "." + }, + { + "name": "client", + "path": "apps/client" + }, + { + "name": "server", + "path": "apps/server" + }, + { + "name": "core", + "path": "packages/core" + }, + { + "name": "ui", + "path": "packages/ui" + }, + { + "name": "theme", + "path": "packages/theme" + }, + { + "name": "calendar", + "path": "packages/calendar" + }, + { + "name": "date-time", + "path": "packages/date-time" + }, + { + "name": "tsup-config", + "path": "configs/tsup-config" + }, + { + "name": "vitest-config", + "path": "configs/vitest-config" + } + ], + "settings": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll.stylelint": "explicit" + }, + "editor.formatOnSave": false, + "typescript.preferences.jsxAttributeCompletionStyle": "none", + "javascript.preferences.jsxAttributeCompletionStyle": "none", + "stylelint.enable": true, + "stylelint.validate": ["css", "scss", "postcss", "typescript", "typescriptreact"], + "stylelint.customSyntax": "postcss-styled-syntax", + "css.validate": false, + "less.validate": false, + "scss.validate": false, + "typescript.tsdk": "node_modules/typescript/lib" + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 3e87b040..f7a4518d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -11,12 +11,12 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( ...pluginQuery.configs['flat/recommended'], - { ignores: ['dist', 'src/components/Icon'] }, + { ignores: ['**/dist/**', '**/Icon/**'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx,js}'], languageOptions: { - ecmaVersion: 2020, + ecmaVersion: 'latest', globals: { ...globals.browser, ...globals.jest, diff --git a/frontend/package.json b/frontend/package.json index 664462fa..573dc973 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,36 +4,18 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "node server", - "build:client": "vite build --outDir dist/client", - "build": "vite build && tsc -b", - "test": "vitest", - "coverage": "vitest run --coverage", + "build": "pnpm -r build", + "dev": "pnpm -r dev", + "test": "pnpm -r test", "lint": "eslint .", - "preview": "vite preview", - "setup-git-hook": "bash src/scripts/git-hook-init-command.sh", - "create-theme": "node src/scripts/create-theme.cjs & pnpm eslint --fix", - "create-icon": "node src/scripts/create-icon.cjs", - "optimize-image": "node src/scripts/optimize-image.cjs", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "setup-git-hook": "bash src/scripts/git-hook-init-command.sh" }, "engines": { "pnpm": ">=9.0.0" }, - "dependencies": { - "@tanstack/react-query": "^5.66.0", - "@tanstack/react-router": "^1.109.2", - "@vanilla-extract/css": "^1.17.0", - "@vanilla-extract/dynamic": "^2.1.2", - "@vanilla-extract/recipes": "^0.5.5", - "express": "^4.21.2", - "jotai": "^2.12.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "zod": "^3.24.1" - }, "devDependencies": { + "@changesets/changelog-git": "^0.2.1", + "@changesets/cli": "^2.29.4", "@chromatic-com/storybook": "^3.2.4", "@eslint/js": "^9.17.0", "@storybook/addon-essentials": "^8.5.2", @@ -47,11 +29,18 @@ "@tanstack/router-devtools": "^1.99.0", "@tanstack/router-plugin": "^1.99.3", "@testing-library/dom": "^10.4.0", - "@testing-library/react": "^16.2.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@vanilla-extract/css": "^1.17.0", + "@vanilla-extract/dynamic": "^2.1.2", + "@vanilla-extract/esbuild-plugin": "2.3.14", + "@vanilla-extract/recipes": "^0.5.5", "@vanilla-extract/vite-plugin": "^4.0.19", "@vitejs/plugin-react": "^4.3.4", + "@vitest/browser": "^3.2.3", + "cross-env": "^7.0.3", "eslint": "^9.17.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react": "^7.37.4", @@ -61,16 +50,26 @@ "eslint-plugin-storybook": "^0.11.2", "globals": "^15.14.0", "jsdom": "^26.0.0", + "playwright": "^1.52.0", "sharp": "^0.33.5", "storybook": "^8.5.2", + "tsc-alias": "^1.8.15", + "tsup": "^8.4.0", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vitest": "^3.0.4" + "vitest": "^3.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "eslintConfig": { "extends": [ "plugin:storybook/recommended" ] + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.2" + } } } diff --git a/frontend/packages/calendar/package.json b/frontend/packages/calendar/package.json new file mode 100644 index 00000000..bbabf62a --- /dev/null +++ b/frontend/packages/calendar/package.json @@ -0,0 +1,36 @@ +{ + "name": "@endolphin/calendar", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "start": "tsup --watch", + "test": "vitest" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@endolphin/core": "workspace:^", + "@endolphin/ui": "workspace:^", + "@endolphin/theme": "workspace:^" + }, + "devDependencies": { + "@endolphin/tsup-config": "workspace:^", + "@endolphin/vitest-config": "workspace:^" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@endolphin/theme": "workspace:^", + "@vanilla-extract/css": "^1.17.0", + "@vanilla-extract/dynamic": "^2.1.2", + "@vanilla-extract/recipes": "^0.5.5" + } +} diff --git a/frontend/src/components/Calendar/Calendar.stories.tsx b/frontend/packages/calendar/src/components/Calendar/Calendar.stories.tsx similarity index 100% rename from frontend/src/components/Calendar/Calendar.stories.tsx rename to frontend/packages/calendar/src/components/Calendar/Calendar.stories.tsx diff --git a/frontend/src/components/Calendar/Core/SelectedWeek.tsx b/frontend/packages/calendar/src/components/Calendar/Core/SelectedWeek.tsx similarity index 87% rename from frontend/src/components/Calendar/Core/SelectedWeek.tsx rename to frontend/packages/calendar/src/components/Calendar/Core/SelectedWeek.tsx index 806df3c7..ef8e26a6 100644 --- a/frontend/src/components/Calendar/Core/SelectedWeek.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Core/SelectedWeek.tsx @@ -1,6 +1,6 @@ -import { isSameDate } from '@/utils/date'; +import { WEEK } from '@endolphin/core/constants'; +import { isSameDate } from '@endolphin/core/utils'; -import { WEEK } from '../../../constants/date'; import { useCalendarContext } from '../context/CalendarContext'; import { sideCellStyle } from '../Table/index.css'; import { weekStyle } from './index.css'; diff --git a/frontend/src/components/Calendar/Core/TimeControl.tsx b/frontend/packages/calendar/src/components/Calendar/Core/TimeControl.tsx similarity index 87% rename from frontend/src/components/Calendar/Core/TimeControl.tsx rename to frontend/packages/calendar/src/components/Calendar/Core/TimeControl.tsx index c8da5b7d..b89f2cae 100644 --- a/frontend/src/components/Calendar/Core/TimeControl.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Core/TimeControl.tsx @@ -1,7 +1,6 @@ -import { Flex } from '@/components/Flex'; -import { formatDateToWeek } from '@/utils/date'; +import { formatDateToWeek } from '@endolphin/core/utils'; +import { Flex, Text } from '@endolphin/ui'; -import { Text } from '../../Text'; import { useCalendarContext } from '../context/CalendarContext'; import { timeControlButtonStyle, timeControlStyle } from './index.css'; import { TimeControlButton } from './TimeControlButton'; diff --git a/frontend/src/components/Calendar/Core/TimeControlButton.tsx b/frontend/packages/calendar/src/components/Calendar/Core/TimeControlButton.tsx similarity index 78% rename from frontend/src/components/Calendar/Core/TimeControlButton.tsx rename to frontend/packages/calendar/src/components/Calendar/Core/TimeControlButton.tsx index 01c9d5d5..e1e7e095 100644 --- a/frontend/src/components/Calendar/Core/TimeControlButton.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Core/TimeControlButton.tsx @@ -1,6 +1,5 @@ -import Button from '@/components/Button'; -import { ChevronLeft, ChevronRight } from '@/components/Icon'; -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; +import { Button, Icon } from '@endolphin/ui'; import { useCalendarContext } from '../context/CalendarContext'; import { timeControlButtonStyle } from './index.css'; @@ -15,7 +14,7 @@ export const TimeControlButton = ({ type }: { type: 'prev' | 'next' | 'today' }) className={timeControlButtonStyle({ order: 'first' })} onClick={handleClickPrevWeek} > - <ChevronLeft clickable fill={vars.color.Ref.Netural[600]} /> + <Icon.ChevronLeft clickable fill={vars.color.Ref.Netural[600]} /> </button> ); case 'next': @@ -25,7 +24,7 @@ export const TimeControlButton = ({ type }: { type: 'prev' | 'next' | 'today' }) className={timeControlButtonStyle({ order: 'last' })} onClick={handleClickNextWeek} > - <ChevronRight clickable fill={vars.color.Ref.Netural[600]} /> + <Icon.ChevronRight clickable fill={vars.color.Ref.Netural[600]} /> </button> ); case 'today': diff --git a/frontend/src/components/Calendar/Core/WeekCell.tsx b/frontend/packages/calendar/src/components/Calendar/Core/WeekCell.tsx similarity index 85% rename from frontend/src/components/Calendar/Core/WeekCell.tsx rename to frontend/packages/calendar/src/components/Calendar/Core/WeekCell.tsx index 2c9cda76..a6279382 100644 --- a/frontend/src/components/Calendar/Core/WeekCell.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Core/WeekCell.tsx @@ -1,7 +1,7 @@ -import type { WEEKDAY } from '@/constants/date'; -import { isSameDate } from '@/utils/date'; +import type { WEEKDAY } from '@endolphin/core/constants'; +import { isSameDate } from '@endolphin/core/utils'; +import { Text } from '@endolphin/ui'; -import { Text } from '../../Text'; import { weekCellBoxStyle, weekCellStyle } from './index.css'; interface WeekCellProps { diff --git a/frontend/src/components/Calendar/Core/index.css.ts b/frontend/packages/calendar/src/components/Calendar/Core/index.css.ts similarity index 93% rename from frontend/src/components/Calendar/Core/index.css.ts rename to frontend/packages/calendar/src/components/Calendar/Core/index.css.ts index 05dd28bd..f1801c2b 100644 --- a/frontend/src/components/Calendar/Core/index.css.ts +++ b/frontend/packages/calendar/src/components/Calendar/Core/index.css.ts @@ -1,9 +1,7 @@ +import { animation, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { fadeHighlightProps } from '@/theme/animation.css'; -import { vars } from '@/theme/index.css'; - export const coreStyle = style({ position: 'sticky', top: 0, @@ -68,8 +66,8 @@ export const timeControlButtonStyle = recipe({ }, }, }); - -export const weekCellStyle = recipe({ + +export const weekCellStyle: ReturnType<typeof recipe> = recipe({ base: { width: '100%', height: 66, @@ -94,7 +92,7 @@ export const weekCellStyle = recipe({ state: { selected: { backgroundColor: vars.color.Ref.Primary[50], - ...fadeHighlightProps, + ...animation.fadeHighlightProps, }, default: { backgroundColor: vars.color.Ref.Netural.White, diff --git a/frontend/src/components/Calendar/Core/index.tsx b/frontend/packages/calendar/src/components/Calendar/Core/index.tsx similarity index 88% rename from frontend/src/components/Calendar/Core/index.tsx rename to frontend/packages/calendar/src/components/Calendar/Core/index.tsx index c2868697..ced17663 100644 --- a/frontend/src/components/Calendar/Core/index.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Core/index.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@/components/Flex'; +import { Flex } from '@endolphin/ui'; import { coreStyle } from './index.css'; import { SelectedWeek } from './SelectedWeek'; diff --git a/frontend/src/components/Calendar/Header/CalendarHeader.tsx b/frontend/packages/calendar/src/components/Calendar/Header/CalendarHeader.tsx similarity index 91% rename from frontend/src/components/Calendar/Header/CalendarHeader.tsx rename to frontend/packages/calendar/src/components/Calendar/Header/CalendarHeader.tsx index edd352ae..e4ee8231 100644 --- a/frontend/src/components/Calendar/Header/CalendarHeader.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Header/CalendarHeader.tsx @@ -1,4 +1,4 @@ -import { isSameDate } from '@/utils/date'; +import { isSameDate } from '@endolphin/core/utils'; import { useCalendarContext } from '../context/CalendarContext'; import { CalendarCell } from '../Table/CalendarCell'; diff --git a/frontend/src/components/Calendar/Header/index.css.ts b/frontend/packages/calendar/src/components/Calendar/Header/index.css.ts similarity index 87% rename from frontend/src/components/Calendar/Header/index.css.ts rename to frontend/packages/calendar/src/components/Calendar/Header/index.css.ts index 81b93d10..8bdb893b 100644 --- a/frontend/src/components/Calendar/Header/index.css.ts +++ b/frontend/packages/calendar/src/components/Calendar/Header/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const headerStyle = style({ width: '100%', display: 'flex', diff --git a/frontend/src/components/Calendar/Table/CalendarCell.tsx b/frontend/packages/calendar/src/components/Calendar/Table/CalendarCell.tsx similarity index 87% rename from frontend/src/components/Calendar/Table/CalendarCell.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/CalendarCell.tsx index 0f3128db..fabf1558 100644 --- a/frontend/src/components/Calendar/Table/CalendarCell.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/CalendarCell.tsx @@ -1,7 +1,7 @@ -import { Flex } from '@/components/Flex'; -import { MINUTES, type Time } from '@/constants/date'; -import { isWeekend } from '@/utils/date'; +import { MINUTES, type Time } from '@endolphin/core/constants'; +import { isWeekend } from '@endolphin/core/utils'; +import { Flex } from '@endolphin/ui'; import { CalendarDetailCell } from './CalendarDetailCell'; import { cellStyle } from './index.css'; diff --git a/frontend/src/components/Calendar/Table/CalendarDay.tsx b/frontend/packages/calendar/src/components/Calendar/Table/CalendarDay.tsx similarity index 91% rename from frontend/src/components/Calendar/Table/CalendarDay.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/CalendarDay.tsx index 3cbad8ff..f67457ed 100644 --- a/frontend/src/components/Calendar/Table/CalendarDay.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/CalendarDay.tsx @@ -1,6 +1,6 @@ +import { TIMES } from '@endolphin/core/constants'; import { memo } from 'react'; -import { TIMES } from '../../../constants/date'; import { CalendarCell } from './CalendarCell'; import { dayStyle } from './index.css'; diff --git a/frontend/src/components/Calendar/Table/CalendarDetailCell.tsx b/frontend/packages/calendar/src/components/Calendar/Table/CalendarDetailCell.tsx similarity index 95% rename from frontend/src/components/Calendar/Table/CalendarDetailCell.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/CalendarDetailCell.tsx index 7b6f3e77..a5a39edc 100644 --- a/frontend/src/components/Calendar/Table/CalendarDetailCell.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/CalendarDetailCell.tsx @@ -1,4 +1,4 @@ -import { isDateInRange } from '@/utils/date'; +import { isDateInRange } from '@endolphin/core/utils'; import { useTimeTableContext } from '../context/TimeTableContext'; import { cellDetailStyle } from './index.css'; diff --git a/frontend/src/components/Calendar/Table/CalendarSide.tsx b/frontend/packages/calendar/src/components/Calendar/Table/CalendarSide.tsx similarity index 81% rename from frontend/src/components/Calendar/Table/CalendarSide.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/CalendarSide.tsx index 45d2a89a..5eef945b 100644 --- a/frontend/src/components/Calendar/Table/CalendarSide.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/CalendarSide.tsx @@ -1,4 +1,5 @@ -import { TIMES } from '../../../constants/date'; +import { TIMES } from '@endolphin/core/constants'; + import { sideStyle } from './index.css'; import { SideCell } from './SideCell'; diff --git a/frontend/src/components/Calendar/Table/SideCell.tsx b/frontend/packages/calendar/src/components/Calendar/Table/SideCell.tsx similarity index 89% rename from frontend/src/components/Calendar/Table/SideCell.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/SideCell.tsx index 88d0ff8d..77eb0ac4 100644 --- a/frontend/src/components/Calendar/Table/SideCell.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/SideCell.tsx @@ -1,5 +1,6 @@ -import type { Time } from '../../../constants/date'; -import { Text } from '../../Text'; +import type { Time } from '@endolphin/core/constants'; +import { Text } from '@endolphin/ui'; + import { sideCellStyle } from './index.css'; export const SideCell = ({ time }: { time: Time }) => { diff --git a/frontend/src/components/Calendar/Table/index.css.ts b/frontend/packages/calendar/src/components/Calendar/Table/index.css.ts similarity index 92% rename from frontend/src/components/Calendar/Table/index.css.ts rename to frontend/packages/calendar/src/components/Calendar/Table/index.css.ts index 8f879c26..15b23633 100644 --- a/frontend/src/components/Calendar/Table/index.css.ts +++ b/frontend/packages/calendar/src/components/Calendar/Table/index.css.ts @@ -1,10 +1,7 @@ +import { animation, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { fadeHighlightProps } from '@/theme/animation.css'; - -import { vars } from '../../../theme/index.css'; - export const containerStyle = style({ width: '100%', display: 'flex', @@ -26,7 +23,7 @@ export const sideStyle = style({ flexDirection: 'column', }); -export const cellStyle = recipe({ +export const cellStyle: ReturnType<typeof recipe> = recipe({ base: { width: '100%', boxShadow: `inset 0 0 0 0.5px ${vars.color.Ref.Netural[200]}`, @@ -54,7 +51,7 @@ export const cellStyle = recipe({ state: { selected: { backgroundColor: vars.color.Ref.Primary[50], - ...fadeHighlightProps, + ...animation.fadeHighlightProps, }, default: {}, }, diff --git a/frontend/src/components/Calendar/Table/index.tsx b/frontend/packages/calendar/src/components/Calendar/Table/index.tsx similarity index 77% rename from frontend/src/components/Calendar/Table/index.tsx rename to frontend/packages/calendar/src/components/Calendar/Table/index.tsx index 2e8bfc3f..f074618c 100644 --- a/frontend/src/components/Calendar/Table/index.tsx +++ b/frontend/packages/calendar/src/components/Calendar/Table/index.tsx @@ -1,8 +1,8 @@ -import type { TimeInfo } from '@/hooks/useSelectTime'; -import { isSameDate } from '@/utils/date'; +import type { TimeInfo } from '@endolphin/core/hooks'; +import { isSameDate } from '@endolphin/core/utils'; import { useCalendarContext } from '../context/CalendarContext'; -import { TimeTableProvider } from '../context/TimeTableProvider'; +import { TimeTableProvider } from '../context/TimeTableContext'; import { CalendarDay } from './CalendarDay'; import { CalendarSide } from './CalendarSide'; import { containerStyle, contentsStyle } from './index.css'; @@ -23,7 +23,7 @@ const CalendarContents = () => { }; export const CalendarTable = ({ context }: { context?: TimeInfo }) => ( - <TimeTableProvider outerContext={context}> + <TimeTableProvider initialValue={context}> <div className={containerStyle}> <CalendarSide /> <CalendarContents /> diff --git a/frontend/packages/calendar/src/components/Calendar/context/CalendarContext.ts b/frontend/packages/calendar/src/components/Calendar/context/CalendarContext.ts new file mode 100644 index 00000000..88f21ceb --- /dev/null +++ b/frontend/packages/calendar/src/components/Calendar/context/CalendarContext.ts @@ -0,0 +1,13 @@ +import { createStateContext } from '@endolphin/core/utils'; +import type { CalendarInfo } from '@hooks/useCalendar'; +import { useCalendar } from '@hooks/useCalendar'; +import type { CalendarSharedInfo } from '@hooks/useSharedCalendar'; + +export const { + StateProvider: CalendarProvider, + useContextState: useCalendarContext, +} = createStateContext< + Partial<CalendarSharedInfo>, + CalendarInfo, + { isTableUsed?: boolean } +>(useCalendar); \ No newline at end of file diff --git a/frontend/packages/calendar/src/components/Calendar/context/SharedCalendarContext.ts b/frontend/packages/calendar/src/components/Calendar/context/SharedCalendarContext.ts new file mode 100644 index 00000000..ff671fb0 --- /dev/null +++ b/frontend/packages/calendar/src/components/Calendar/context/SharedCalendarContext.ts @@ -0,0 +1,7 @@ +import { createStateContext } from '@endolphin/core/utils'; +import { useSharedCalendar } from '@hooks/useSharedCalendar'; + +export const { + StateProvider: SharedCalendarProvider, + useContextState: useSharedCalendarContext, +} = createStateContext(useSharedCalendar); \ No newline at end of file diff --git a/frontend/packages/calendar/src/components/Calendar/context/TimeTableContext.ts b/frontend/packages/calendar/src/components/Calendar/context/TimeTableContext.ts new file mode 100644 index 00000000..d2e7bfad --- /dev/null +++ b/frontend/packages/calendar/src/components/Calendar/context/TimeTableContext.ts @@ -0,0 +1,7 @@ +import type { TimeInfo } from '@endolphin/core/hooks'; +import { createStateContext } from '@endolphin/core/utils'; + +export const { + StateProvider: TimeTableProvider, + useContextState: useTimeTableContext, +} = createStateContext<TimeInfo, TimeInfo, object>(); \ No newline at end of file diff --git a/frontend/src/components/Calendar/index.css.ts b/frontend/packages/calendar/src/components/Calendar/index.css.ts similarity index 100% rename from frontend/src/components/Calendar/index.css.ts rename to frontend/packages/calendar/src/components/Calendar/index.css.ts diff --git a/frontend/src/components/Calendar/index.tsx b/frontend/packages/calendar/src/components/Calendar/index.tsx similarity index 69% rename from frontend/src/components/Calendar/index.tsx rename to frontend/packages/calendar/src/components/Calendar/index.tsx index ca93a671..c673b7ff 100644 --- a/frontend/src/components/Calendar/index.tsx +++ b/frontend/packages/calendar/src/components/Calendar/index.tsx @@ -1,16 +1,15 @@ -import type { PropsWithChildren } from 'react'; -import { isValidElement } from 'react'; +import { clsx } from '@endolphin/core/utils'; +import type { CalendarSharedInfo } from '@hooks/useSharedCalendar'; +import { isValidElement, type PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - -import { CalendarProvider } from './context/CalendarProvider'; -import type { CalendarSharedInfo } from './context/SharedCalendarContext'; +import { CalendarProvider } from './context/CalendarContext'; import { Core } from './Core'; import { CalendarHeader } from './Header/CalendarHeader'; import { calendarStyle } from './index.css'; import { CalendarTable } from './Table'; interface CalendarProps extends Partial<CalendarSharedInfo>, PropsWithChildren { + isTableUsed?: boolean; className?: string; } @@ -23,7 +22,7 @@ export const Calendar = ({ className, children, ...context }: CalendarProps) => })(); return ( - <CalendarProvider isTableUsed={isTableUsed} outerContext={context}> + <CalendarProvider extraProp={{ isTableUsed }} initialValue={context}> <div className={clsx(className, calendarStyle)}> {children} </div> diff --git a/frontend/src/components/DatePicker/DatePicker.stories.tsx b/frontend/packages/calendar/src/components/DatePicker/DatePicker.stories.tsx similarity index 84% rename from frontend/src/components/DatePicker/DatePicker.stories.tsx rename to frontend/packages/calendar/src/components/DatePicker/DatePicker.stories.tsx index d7c81b14..1094c6e8 100644 --- a/frontend/src/components/DatePicker/DatePicker.stories.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/DatePicker.stories.tsx @@ -1,12 +1,11 @@ +import { Input } from '@endolphin/ui'; +import { useHighlightRange } from '@hooks/useDatePicker/useHighlightRange'; +import { useMonthNavigation } from '@hooks/useDatePicker/useMonthNavigation'; +import { useSharedCalendar } from '@hooks/useSharedCalendar'; import type { Meta } from '@storybook/react'; import { useState } from 'react'; -import { useHighlightRange } from '@/hooks/useDatePicker/useHighlightRange'; -import { useMonthNavigation } from '@/hooks/useDatePicker/useMonthNavigation'; -import { useSharedCalendar } from '@/hooks/useSharedCalendar'; - -import { SharedCalendarContext } from '../Calendar/context/SharedCalendarContext'; -import Input from '../Input'; +import { SharedCalendarProvider } from '../Calendar/context/SharedCalendarContext'; import DatePicker from '.'; import { injectedContainerStyle } from './datePicker.stories.css'; @@ -93,13 +92,13 @@ export const SyncWithSharedContext = () => { const monthNavigation = useMonthNavigation(); return ( - <SharedCalendarContext.Provider value={props}> + <SharedCalendarProvider initialValue={props}> <DatePicker.Select handleDateSelect={handleSelectDate} selectedDate={selectedDate} {...monthNavigation} /> {/* <Calendar {...props}/> */} - </SharedCalendarContext.Provider> + </SharedCalendarProvider> ); }; diff --git a/frontend/src/components/DatePicker/DatePickerRange.tsx b/frontend/packages/calendar/src/components/DatePicker/DatePickerRange.tsx similarity index 96% rename from frontend/src/components/DatePicker/DatePickerRange.tsx rename to frontend/packages/calendar/src/components/DatePicker/DatePickerRange.tsx index 139663c0..97ea9aa1 100644 --- a/frontend/src/components/DatePicker/DatePickerRange.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/DatePickerRange.tsx @@ -1,7 +1,6 @@ +import { useClickOutside } from '@endolphin/core/hooks'; import { useState } from 'react'; -import { useClickOutside } from '@/hooks/useClickOutside'; - import type { CommonDatePickerProps } from '.'; import DatePickerRangeProvider from './context/DatePickerRangeProvider'; import Header from './Header'; diff --git a/frontend/src/components/DatePicker/DatePickerSelect.tsx b/frontend/packages/calendar/src/components/DatePicker/DatePickerSelect.tsx similarity index 96% rename from frontend/src/components/DatePicker/DatePickerSelect.tsx rename to frontend/packages/calendar/src/components/DatePicker/DatePickerSelect.tsx index d0544145..87d67828 100644 --- a/frontend/src/components/DatePicker/DatePickerSelect.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/DatePickerSelect.tsx @@ -1,7 +1,6 @@ +import { useClickOutside } from '@endolphin/core/hooks'; import { useState } from 'react'; -import { useClickOutside } from '@/hooks/useClickOutside'; - import type { CommonDatePickerProps } from '.'; import DatePickerSelectProvider from './context/DatePickerSelectProvider'; import Header from './Header'; diff --git a/frontend/src/components/DatePicker/Header/index.css.ts b/frontend/packages/calendar/src/components/DatePicker/Header/index.css.ts similarity index 89% rename from frontend/src/components/DatePicker/Header/index.css.ts rename to frontend/packages/calendar/src/components/DatePicker/Header/index.css.ts index 17864259..3f4e8d4d 100644 --- a/frontend/src/components/DatePicker/Header/index.css.ts +++ b/frontend/packages/calendar/src/components/DatePicker/Header/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const headerStyle = style({ display: 'flex', paddingLeft: vars.spacing[200], diff --git a/frontend/src/components/DatePicker/Header/index.tsx b/frontend/packages/calendar/src/components/DatePicker/Header/index.tsx similarity index 59% rename from frontend/src/components/DatePicker/Header/index.tsx rename to frontend/packages/calendar/src/components/DatePicker/Header/index.tsx index da825463..4e012664 100644 --- a/frontend/src/components/DatePicker/Header/index.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/Header/index.tsx @@ -1,38 +1,36 @@ -import { Flex } from '@/components/Flex'; -import { ChevronLeft, ChevronRight } from '@/components/Icon'; -import { Text } from '@/components/Text'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import { vars } from '@/theme/index.css'; -import { getDateParts } from '@/utils/date'; +import { getDateParts } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; +import { Flex, Icon, Text } from '@endolphin/ui'; +import type { KeyboardEvent } from 'react'; -import { DatePickerContext } from '../context/DatePickerContext'; +import { useDatePickerContext } from '../context/DatePickerContext'; import { chevronWrapper, headerStyle } from './index.css'; const Header = () => { - const { baseDate, gotoNextMonth, gotoPrevMonth } = useSafeContext(DatePickerContext); + const { baseDate, gotoNextMonth, gotoPrevMonth } = useDatePickerContext(); const { year: currentYear, month: currentMonth } = getDateParts(baseDate); return ( <div className={headerStyle}> <Text typo='b1M'>{`${currentYear}년 ${currentMonth + 1}월`}</Text> <Flex direction='row'> <span className={chevronWrapper}> - <ChevronLeft + <Icon.ChevronLeft aria-label='이전 달로 이동' clickable={true} fill={vars.color.Ref.Netural[500]} onClick={gotoPrevMonth} - onKeyDown={(e) => e.key === 'Enter' && gotoPrevMonth()} + onKeyDown={(e: KeyboardEvent<SVGSVGElement>) => e.key === 'Enter' && gotoPrevMonth()} role='button' tabIndex={0} /> </span> <span className={chevronWrapper}> - <ChevronRight + <Icon.ChevronRight aria-label='다음 달로 이동' clickable={true} fill={vars.color.Ref.Netural[500]} onClick={gotoNextMonth} - onKeyDown={(e) => e.key === 'Enter' && gotoNextMonth()} + onKeyDown={(e: KeyboardEvent<SVGSVGElement>) => e.key === 'Enter' && gotoNextMonth()} role='button' tabIndex={0} /> diff --git a/frontend/src/components/DatePicker/RootContainer.tsx b/frontend/packages/calendar/src/components/DatePicker/RootContainer.tsx similarity index 88% rename from frontend/src/components/DatePicker/RootContainer.tsx rename to frontend/packages/calendar/src/components/DatePicker/RootContainer.tsx index 96a2519d..7de431bb 100644 --- a/frontend/src/components/DatePicker/RootContainer.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/RootContainer.tsx @@ -1,7 +1,6 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - import { rootContainerStyle } from './index.css'; interface RootContainerProps extends PropsWithChildren { diff --git a/frontend/src/components/DatePicker/Table/Cell/CellWrapper.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/CellWrapper.tsx similarity index 87% rename from frontend/src/components/DatePicker/Table/Cell/CellWrapper.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Cell/CellWrapper.tsx index 27182aca..c6d77b34 100644 --- a/frontend/src/components/DatePicker/Table/Cell/CellWrapper.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/CellWrapper.tsx @@ -1,9 +1,8 @@ +import { clsx } from '@endolphin/core/utils'; +import { Text } from '@endolphin/ui'; import type { PropsWithChildren } from 'react'; -import { Text } from '@/components/Text'; -import clsx from '@/utils/clsx'; - import { cellWrapperStyle } from './index.css'; interface CellWrapperProps extends PropsWithChildren { diff --git a/frontend/src/components/DatePicker/Table/Cell/DateCell.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/DateCell.tsx similarity index 91% rename from frontend/src/components/DatePicker/Table/Cell/DateCell.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Cell/DateCell.tsx index d7196041..383ba8d9 100644 --- a/frontend/src/components/DatePicker/Table/Cell/DateCell.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/DateCell.tsx @@ -1,11 +1,9 @@ +import { isSameDate, isSaturday, isSunday } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; import { assignInlineVars } from '@vanilla-extract/dynamic'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import { vars } from '@/theme/index.css'; -import { isSameDate, isSaturday, isSunday } from '@/utils/date'; - import type { DatePickerType } from '../..'; -import { DatePickerContext } from '../../context/DatePickerContext'; +import { useDatePickerContext } from '../../context/DatePickerContext'; import type { HighlightState } from '../Highlight'; import { cellThemeVars } from '../index.css'; import CellWrapper from './CellWrapper'; @@ -33,7 +31,7 @@ export const DateCell = ({ baseDate, highlightState, }: DateCellProps) => { - const { calendarType, onDateCellClick } = useSafeContext(DatePickerContext); + const { calendarType, onDateCellClick } = useDatePickerContext(); const todayCellStyle = getTodayCellStyle(calendarType); const selectedCellStyle = getSelectedCellStyle(calendarType); const inlineCellStyles = assignInlineVars(cellThemeVars, { diff --git a/frontend/src/components/DatePicker/Table/Cell/DowCell.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/DowCell.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/Cell/DowCell.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Cell/DowCell.tsx diff --git a/frontend/src/components/DatePicker/Table/Cell/index.css.ts b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.css.ts similarity index 96% rename from frontend/src/components/DatePicker/Table/Cell/index.css.ts rename to frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.css.ts index 6fbcf526..68fb5f65 100644 --- a/frontend/src/components/DatePicker/Table/Cell/index.css.ts +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - import { cellThemeVars } from '../index.css'; export const cellWrapperStyle = recipe({ diff --git a/frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.ts b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.ts new file mode 100644 index 00000000..72ebd221 --- /dev/null +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Cell/index.ts @@ -0,0 +1,2 @@ +export * from './DateCell'; +export * from './DowCell'; \ No newline at end of file diff --git a/frontend/src/components/DatePicker/Table/Highlight/HighlightBox.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Highlight/HighlightBox.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/Highlight/HighlightBox.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Highlight/HighlightBox.tsx diff --git a/frontend/src/components/DatePicker/Table/Highlight/HighlightGap.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Highlight/HighlightGap.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/Highlight/HighlightGap.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Highlight/HighlightGap.tsx diff --git a/frontend/src/components/DatePicker/Table/Highlight/index.css.ts b/frontend/packages/calendar/src/components/DatePicker/Table/Highlight/index.css.ts similarity index 97% rename from frontend/src/components/DatePicker/Table/Highlight/index.css.ts rename to frontend/packages/calendar/src/components/DatePicker/Table/Highlight/index.css.ts index c246a82c..dcb5dd0c 100644 --- a/frontend/src/components/DatePicker/Table/Highlight/index.css.ts +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Highlight/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const highlightBoxStyle = recipe({ base: { padding: vars.spacing[100], diff --git a/frontend/src/components/DatePicker/Table/Highlight/index.ts b/frontend/packages/calendar/src/components/DatePicker/Table/Highlight/index.ts similarity index 100% rename from frontend/src/components/DatePicker/Table/Highlight/index.ts rename to frontend/packages/calendar/src/components/DatePicker/Table/Highlight/index.ts diff --git a/frontend/src/components/DatePicker/Table/Row.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/Row.tsx similarity index 87% rename from frontend/src/components/DatePicker/Table/Row.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/Row.tsx index 81f7de43..e6455c1b 100644 --- a/frontend/src/components/DatePicker/Table/Row.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/Table/Row.tsx @@ -1,7 +1,6 @@ -import { useSafeContext } from '@/hooks/useSafeContext'; -import { isSameDate } from '@/utils/date'; +import { isSameDate } from '@endolphin/core/utils'; -import { DatePickerContext } from '../context/DatePickerContext'; +import { useDatePickerContext } from '../context/DatePickerContext'; import { DateCell } from './Cell'; import type { HighlightRange, HighlightState } from './Highlight'; import HighlightBox from './Highlight/HighlightBox'; @@ -13,7 +12,7 @@ interface RowProps { } const Row = ({ weekDates }: RowProps) => { - const { baseDate, highlightRange, isDateSelected } = useSafeContext(DatePickerContext); + const { baseDate, highlightRange, isDateSelected } = useDatePickerContext(); return ( <RowContainer> diff --git a/frontend/src/components/DatePicker/Table/RowContainer.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/RowContainer.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/RowContainer.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/RowContainer.tsx diff --git a/frontend/src/components/DatePicker/Table/TableBody.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/TableBody.tsx similarity index 57% rename from frontend/src/components/DatePicker/Table/TableBody.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/TableBody.tsx index 3af2c941..431bdd10 100644 --- a/frontend/src/components/DatePicker/Table/TableBody.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/Table/TableBody.tsx @@ -1,13 +1,12 @@ -import { Flex } from '@/components/Flex'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import { generateMonthCalendar } from '@/utils/date/calendar/calendarGeneration'; +import { Flex } from '@endolphin/ui'; -import { DatePickerContext } from '../context/DatePickerContext'; +import { useDatePickerContext } from '../context/DatePickerContext'; +import { generateMonthCalendar } from '../datePickerHelper'; import Row from './Row'; const TableBody = () => { - const { baseDate } = useSafeContext(DatePickerContext); + const { baseDate } = useDatePickerContext(); const calendarDates = generateMonthCalendar(baseDate ?? new Date()); return ( <Flex diff --git a/frontend/src/components/DatePicker/Table/TableHeader.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/TableHeader.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/TableHeader.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/TableHeader.tsx diff --git a/frontend/src/components/DatePicker/Table/index.css.ts b/frontend/packages/calendar/src/components/DatePicker/Table/index.css.ts similarity index 94% rename from frontend/src/components/DatePicker/Table/index.css.ts rename to frontend/packages/calendar/src/components/DatePicker/Table/index.css.ts index c59cede0..052256ff 100644 --- a/frontend/src/components/DatePicker/Table/index.css.ts +++ b/frontend/packages/calendar/src/components/DatePicker/Table/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { createThemeContract, style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const cellThemeVars = createThemeContract({ todayCellBackgroundColor: '--today-cell-background', todayCellColor: '--today-cell-color', diff --git a/frontend/src/components/DatePicker/Table/index.tsx b/frontend/packages/calendar/src/components/DatePicker/Table/index.tsx similarity index 100% rename from frontend/src/components/DatePicker/Table/index.tsx rename to frontend/packages/calendar/src/components/DatePicker/Table/index.tsx diff --git a/frontend/src/components/DatePicker/context/DatePickerContext.ts b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerContext.ts similarity index 51% rename from frontend/src/components/DatePicker/context/DatePickerContext.ts rename to frontend/packages/calendar/src/components/DatePicker/context/DatePickerContext.ts index bff070ad..5f35f80a 100644 --- a/frontend/src/components/DatePicker/context/DatePickerContext.ts +++ b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerContext.ts @@ -1,6 +1,6 @@ -import { createContext } from 'react'; +import { createStateContext } from '@endolphin/core/utils'; -import type { CommonDatePickerProps, DatePickerType } from '..'; +import type { CommonDatePickerProps, DatePickerType } from '..'; import type { HighlightRange } from '../Table/Highlight'; export interface DatePickerContextProps extends CommonDatePickerProps { @@ -11,4 +11,7 @@ export interface DatePickerContextProps extends CommonDatePickerProps { isDateSelected: (date: Date) => boolean; } -export const DatePickerContext = createContext<DatePickerContextProps | null>(null); +export const { + StateProvider: DatePickerProvider, + useContextState: useDatePickerContext, +} = createStateContext<DatePickerContextProps, DatePickerContextProps, object>(); diff --git a/frontend/src/components/DatePicker/context/DatePickerRangeProvider.tsx b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerRangeProvider.tsx similarity index 79% rename from frontend/src/components/DatePicker/context/DatePickerRangeProvider.tsx rename to frontend/packages/calendar/src/components/DatePicker/context/DatePickerRangeProvider.tsx index e3eb76eb..2af6d973 100644 --- a/frontend/src/components/DatePicker/context/DatePickerRangeProvider.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerRangeProvider.tsx @@ -1,10 +1,9 @@ +import { isSameDate } from '@endolphin/core/utils'; +import { useDatePickerRange } from '@hooks/useDatePicker/useDatePickerRange'; import { type PropsWithChildren, useCallback } from 'react'; -import { useDatePickerRange } from '@/hooks/useDatePicker/useDatePickerRange'; -import { isSameDate } from '@/utils/date'; - import type { DatePickerRangeProps } from '../DatePickerRange'; -import { DatePickerContext } from './DatePickerContext'; +import { DatePickerProvider } from './DatePickerContext'; interface DatePickerRangeProviderProps extends PropsWithChildren, DatePickerRangeProps {} @@ -26,8 +25,8 @@ const DatePickerRangeProvider = ({ }, [highlightRange]); return ( - <DatePickerContext.Provider - value={{ + <DatePickerProvider + initialValue={{ calendarType: 'range', onDateCellClick, isDateSelected, @@ -37,7 +36,7 @@ const DatePickerRangeProvider = ({ }} > {children} - </DatePickerContext.Provider> + </DatePickerProvider> ); }; diff --git a/frontend/src/components/DatePicker/context/DatePickerSelectProvider.tsx b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerSelectProvider.tsx similarity index 72% rename from frontend/src/components/DatePicker/context/DatePickerSelectProvider.tsx rename to frontend/packages/calendar/src/components/DatePicker/context/DatePickerSelectProvider.tsx index 8c291ea5..4fd25d52 100644 --- a/frontend/src/components/DatePicker/context/DatePickerSelectProvider.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/context/DatePickerSelectProvider.tsx @@ -1,10 +1,9 @@ +import { isSameDate } from '@endolphin/core/utils'; +import { useDatePickerSelect } from '@hooks/useDatePicker/useDatePickerSelect'; import { type PropsWithChildren } from 'react'; -import { useDatePickerSelect } from '@/hooks/useDatePicker/useDatePickerSelect'; -import { isSameDate } from '@/utils/date'; - import type { DatePickerSelectProps } from '../DatePickerSelect'; -import { DatePickerContext } from './DatePickerContext'; +import { DatePickerProvider } from './DatePickerContext'; interface DatePickerRangeProviderProps extends PropsWithChildren, DatePickerSelectProps {} @@ -17,8 +16,8 @@ const DatePickerSelectProvider = ({ props.selectedDate ? isSameDate(date, props.selectedDate) : false; return ( - <DatePickerContext.Provider - value={{ + <DatePickerProvider + initialValue={{ ...props, calendarType: 'select', onDateCellClick, @@ -27,7 +26,7 @@ const DatePickerSelectProvider = ({ }} > {children} - </DatePickerContext.Provider> + </DatePickerProvider> ); }; diff --git a/frontend/src/components/DatePicker/datePicker.stories.css.ts b/frontend/packages/calendar/src/components/DatePicker/datePicker.stories.css.ts similarity index 100% rename from frontend/src/components/DatePicker/datePicker.stories.css.ts rename to frontend/packages/calendar/src/components/DatePicker/datePicker.stories.css.ts diff --git a/frontend/src/utils/date/calendar/calendarGeneration.ts b/frontend/packages/calendar/src/components/DatePicker/datePickerHelper.ts similarity index 98% rename from frontend/src/utils/date/calendar/calendarGeneration.ts rename to frontend/packages/calendar/src/components/DatePicker/datePickerHelper.ts index da93c3ca..28efceb0 100644 --- a/frontend/src/utils/date/calendar/calendarGeneration.ts +++ b/frontend/packages/calendar/src/components/DatePicker/datePickerHelper.ts @@ -1,4 +1,4 @@ -import { FIRST_DAY, WEEK_DAYS } from '.'; +import { FIRST_DAY, WEEK_DAYS } from '@endolphin/core/utils'; /** * 주어진 연도와 월(0-indexed)을 기반으로 해당 달의 총 일수를 반환합니다. diff --git a/frontend/src/components/DatePicker/index.css.ts b/frontend/packages/calendar/src/components/DatePicker/index.css.ts similarity index 96% rename from frontend/src/components/DatePicker/index.css.ts rename to frontend/packages/calendar/src/components/DatePicker/index.css.ts index d4cfbc4c..258a9242 100644 --- a/frontend/src/components/DatePicker/index.css.ts +++ b/frontend/packages/calendar/src/components/DatePicker/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/frontend/src/components/DatePicker/index.tsx b/frontend/packages/calendar/src/components/DatePicker/index.tsx similarity index 90% rename from frontend/src/components/DatePicker/index.tsx rename to frontend/packages/calendar/src/components/DatePicker/index.tsx index 1616a2ac..9f333b5b 100644 --- a/frontend/src/components/DatePicker/index.tsx +++ b/frontend/packages/calendar/src/components/DatePicker/index.tsx @@ -1,5 +1,4 @@ - -import type { ReactNode } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; import DatePickerRange from './DatePickerRange'; import DatePickerSelect from './DatePickerSelect'; diff --git a/frontend/src/hooks/useCalendar.ts b/frontend/packages/calendar/src/hooks/useCalendar.ts similarity index 88% rename from frontend/src/hooks/useCalendar.ts rename to frontend/packages/calendar/src/hooks/useCalendar.ts index 4844a276..9ca50658 100644 --- a/frontend/src/hooks/useCalendar.ts +++ b/frontend/packages/calendar/src/hooks/useCalendar.ts @@ -1,8 +1,7 @@ +import { formatDateToWeekDates, isObjectEmpty } from '@endolphin/core/utils'; import { useState } from 'react'; -import type { CalendarSharedInfo } from '@/components/Calendar/context/SharedCalendarContext'; -import { isObjectEmpty } from '@/utils/common'; -import { formatDateToWeekDates } from '@/utils/date'; +import type { CalendarSharedInfo } from './useSharedCalendar'; export interface CalendarInfo { selected: Date; diff --git a/frontend/src/hooks/useDatePicker/useDatePickerRange.ts b/frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerRange.ts similarity index 81% rename from frontend/src/hooks/useDatePicker/useDatePickerRange.ts rename to frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerRange.ts index 5988cfff..21d6effb 100644 --- a/frontend/src/hooks/useDatePicker/useDatePickerRange.ts +++ b/frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerRange.ts @@ -1,5 +1,4 @@ - -import type { HighlightRange } from '@/components/DatePicker/Table/Highlight'; +import type { HighlightRange } from '@components/DatePicker/Table/Highlight'; interface UseDatePickerRangeProps { highlightRange: HighlightRange; @@ -21,7 +20,6 @@ export const useDatePickerRange = ({ start ? trimTime(start) : null, end ? trimTime(end) : null, ]; - if (!dateStart) { setHighlightStart(timeTrimmedDate); return; @@ -31,7 +29,14 @@ export const useDatePickerRange = ({ else setHighlightEnd(timeTrimmedDate); return; } - + if (dateStart > timeTrimmedDate) { + setHighlightStart(timeTrimmedDate); + return; + } + if (dateEnd < timeTrimmedDate) { + setHighlightEnd(timeTrimmedDate); + return; + } setHighlightStart(null); setHighlightEnd(null); }; diff --git a/frontend/src/hooks/useDatePicker/useDatePickerSelect.ts b/frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerSelect.ts similarity index 90% rename from frontend/src/hooks/useDatePicker/useDatePickerSelect.ts rename to frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerSelect.ts index 14c1d72a..5e2108d2 100644 --- a/frontend/src/hooks/useDatePicker/useDatePickerSelect.ts +++ b/frontend/packages/calendar/src/hooks/useDatePicker/useDatePickerSelect.ts @@ -1,6 +1,5 @@ -import { formatDateToWeekDates, getDateParts } from '@/utils/date'; -import { getDaysInMonth } from '@/utils/date/calendar'; +import { formatDateToWeekDates, getDateParts, getDaysInMonth } from '@endolphin/core/utils'; interface UseDatePickerSelectProps { baseDate: Date; diff --git a/frontend/src/hooks/useDatePicker/useHighlightRange.ts b/frontend/packages/calendar/src/hooks/useDatePicker/useHighlightRange.ts similarity index 86% rename from frontend/src/hooks/useDatePicker/useHighlightRange.ts rename to frontend/packages/calendar/src/hooks/useDatePicker/useHighlightRange.ts index a61dc6cb..1a1f83c4 100644 --- a/frontend/src/hooks/useDatePicker/useHighlightRange.ts +++ b/frontend/packages/calendar/src/hooks/useDatePicker/useHighlightRange.ts @@ -1,7 +1,6 @@ +import type { HighlightRange } from '@components/DatePicker/Table/Highlight'; import { useState } from 'react'; -import type { HighlightRange } from '@/components/DatePicker/Table/Highlight'; - export const useHighlightRange = () => { const [highlightRange, setHighlightRange] = useState<HighlightRange>({ start: null, end: null }); diff --git a/frontend/src/hooks/useDatePicker/useMonthNavigation.ts b/frontend/packages/calendar/src/hooks/useDatePicker/useMonthNavigation.ts similarity index 86% rename from frontend/src/hooks/useDatePicker/useMonthNavigation.ts rename to frontend/packages/calendar/src/hooks/useDatePicker/useMonthNavigation.ts index a5c6bfc2..ddc83beb 100644 --- a/frontend/src/hooks/useDatePicker/useMonthNavigation.ts +++ b/frontend/packages/calendar/src/hooks/useDatePicker/useMonthNavigation.ts @@ -1,7 +1,6 @@ +import { getNextMonthInfo, getPrevMonthInfo } from '@endolphin/core/utils'; import { useState } from 'react'; -import { getNextMonthInfo, getPrevMonthInfo } from '@/utils/date/calendar'; - export const useMonthNavigation = (initialBaseDate?: Date) => { const [baseDate, setBaseDate] = useState(initialBaseDate ?? new Date()); diff --git a/frontend/src/hooks/useSharedCalendar.ts b/frontend/packages/calendar/src/hooks/useSharedCalendar.ts similarity index 69% rename from frontend/src/hooks/useSharedCalendar.ts rename to frontend/packages/calendar/src/hooks/useSharedCalendar.ts index c0bb3e57..dc0c4f9b 100644 --- a/frontend/src/hooks/useSharedCalendar.ts +++ b/frontend/packages/calendar/src/hooks/useSharedCalendar.ts @@ -1,10 +1,18 @@ +import { formatDateToWeekDates } from '@endolphin/core/utils'; import { useState } from 'react'; -import type { CalendarSharedInfo } from '@/components/Calendar/context/SharedCalendarContext'; - -import { formatDateToWeekDates } from '../utils/date'; import { useMonthNavigation } from './useDatePicker/useMonthNavigation'; +export interface CalendarSharedInfo { + selectedDate: Date; + selectedWeek: Date[]; + handleSelectDate: (date: Date) => void; + today: Date; + baseDate: Date; + gotoPrevMonth: () => void; + gotoNextMonth: () => void; +} + export const useSharedCalendar = (): CalendarSharedInfo => { const [selected, setSelected] = useState(new Date()); const { baseDate, setBaseDate, gotoPrevMonth, gotoNextMonth } = useMonthNavigation(); diff --git a/frontend/packages/calendar/src/index.ts b/frontend/packages/calendar/src/index.ts new file mode 100644 index 00000000..b728bde5 --- /dev/null +++ b/frontend/packages/calendar/src/index.ts @@ -0,0 +1,9 @@ +export { Calendar } from '@components/Calendar'; +export { useCalendarContext } from '@components/Calendar/context/CalendarContext'; +export { + SharedCalendarProvider, + useSharedCalendarContext, +} from '@components/Calendar/context/SharedCalendarContext'; +export { default as DatePicker } from '@components/DatePicker'; +export { useMonthNavigation } from '@hooks/useDatePicker/useMonthNavigation'; +export { useSharedCalendar } from '@hooks/useSharedCalendar'; \ No newline at end of file diff --git a/frontend/packages/calendar/tsconfig.json b/frontend/packages/calendar/tsconfig.json new file mode 100644 index 00000000..a9e4149e --- /dev/null +++ b/frontend/packages/calendar/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "types": ["vitest/globals"], + "paths": { + "@/*": ["src/*"], + "@constants/*": ["src/constants/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@components/*": ["src/components/*"], + }, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + + /* Module Resolution */ + "composite": false, + "outDir": "./dist", + }, + "include": ["src"], +} \ No newline at end of file diff --git a/frontend/packages/calendar/tsup.config.ts b/frontend/packages/calendar/tsup.config.ts new file mode 100644 index 00000000..e700a7df --- /dev/null +++ b/frontend/packages/calendar/tsup.config.ts @@ -0,0 +1,10 @@ +import { reactConfig } from '@endolphin/tsup-config'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + ...reactConfig, + external: ['@endolphin/theme'], + banner: { + js: 'import \'./index.css\';', + }, +}); diff --git a/frontend/packages/calendar/vitest.config.ts b/frontend/packages/calendar/vitest.config.ts new file mode 100644 index 00000000..faae7211 --- /dev/null +++ b/frontend/packages/calendar/vitest.config.ts @@ -0,0 +1,17 @@ +import { createAlias, reactConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + reactConfig, + defineProject({ + root: dirname, + resolve: { + alias: createAlias(dirname), + }, + }), +); \ No newline at end of file diff --git a/frontend/packages/core/package.json b/frontend/packages/core/package.json new file mode 100644 index 00000000..ce8cb86c --- /dev/null +++ b/frontend/packages/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@endolphin/core", + "version": "1.0.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + "./utils": "./dist/src/utils/index.js", + "./hooks": "./dist/src/hooks/index.js", + "./constants": "./dist/src/constants/index.js", + "./types": "./dist/src/types/index.js" + }, + "scripts": { + "build": "tsc -b && tsc-alias", + "test": "vitest" + }, + "devDependencies": { + "@endolphin/vitest-config": "workspace:^" + } +} diff --git a/frontend/packages/core/src/constants/date.ts b/frontend/packages/core/src/constants/date.ts new file mode 100644 index 00000000..1fb538df --- /dev/null +++ b/frontend/packages/core/src/constants/date.ts @@ -0,0 +1,32 @@ +export type Time = number | 'all' | 'empty'; + +export type WEEKDAY = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT'; + +export const TIMES: readonly number[] = Object.freeze(new Array(24).fill(0) + .map((_, i) => i)); + +export const WEEK: readonly WEEKDAY[] = Object.freeze([ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]); + +export const WEEK_MAP: Record<string, string> = Object.freeze({ + 1: '첫째주', + 2: '둘째주', + 3: '셋째주', + 4: '넷째주', + 5: '다섯째주', +}); + +export const MINUTES = Object.freeze(new Array(4).fill(0) + .map((_, i) => i * 15)); + +export const MINUTES_HALF = (totalTime: number, startTime: number) => + Object.freeze(new Array(totalTime * 2).fill(0) + .map((_, i) => startTime + i * 30)); +export const TIME_HEIGHT = 66; diff --git a/frontend/packages/core/src/constants/index.ts b/frontend/packages/core/src/constants/index.ts new file mode 100644 index 00000000..ca6993bb --- /dev/null +++ b/frontend/packages/core/src/constants/index.ts @@ -0,0 +1 @@ +export * from './date'; \ No newline at end of file diff --git a/frontend/packages/core/src/hooks/index.ts b/frontend/packages/core/src/hooks/index.ts new file mode 100644 index 00000000..f9880f43 --- /dev/null +++ b/frontend/packages/core/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useClickOutside'; +export * from './useSafeContext'; +export * from './useSelectTime'; \ No newline at end of file diff --git a/frontend/src/hooks/useClickOutside.ts b/frontend/packages/core/src/hooks/useClickOutside.ts similarity index 100% rename from frontend/src/hooks/useClickOutside.ts rename to frontend/packages/core/src/hooks/useClickOutside.ts diff --git a/frontend/src/hooks/useSafeContext.ts b/frontend/packages/core/src/hooks/useSafeContext.ts similarity index 100% rename from frontend/src/hooks/useSafeContext.ts rename to frontend/packages/core/src/hooks/useSafeContext.ts diff --git a/frontend/src/hooks/useSelectTime.ts b/frontend/packages/core/src/hooks/useSelectTime.ts similarity index 91% rename from frontend/src/hooks/useSelectTime.ts rename to frontend/packages/core/src/hooks/useSelectTime.ts index 81b5abcd..3cf57154 100644 --- a/frontend/src/hooks/useSelectTime.ts +++ b/frontend/packages/core/src/hooks/useSelectTime.ts @@ -1,7 +1,6 @@ +import { sortDate } from '@utils/date'; import { useReducer } from 'react'; -import { sortDate } from '@/utils/date'; - export interface TimeRange { startTime: Date | null; endTime: Date | null; @@ -21,6 +20,8 @@ type Action = { callback?: Callback; }; +const OFFSET = 15 * 60 * 1000; + const resetSelectedTime = () => ({ startTime: null, endTime: null, @@ -32,7 +33,6 @@ const resetDoneTime = () => ({ }); const calcDoneTimeRange = (selectedTime: TimeRange) => { - const OFFSET = 15 * 60 * 1000; if (selectedTime.startTime && !selectedTime.endTime) { return { startTime: selectedTime.startTime, @@ -57,7 +57,11 @@ const selectReducer = (state: State, action: Action) => { case 'SELECT_PROGRESS': if (!state.isSelecting || !action.date) return state; return { - ...state, selectedTime: { ...state.selectedTime, endTime: action.date }, + ...state, + selectedTime: { + ...state.selectedTime, + endTime: new Date(action.date.getTime() + OFFSET), + }, }; case 'SELECT_END': { @@ -83,6 +87,7 @@ const selectReducer = (state: State, action: Action) => { }; export interface TimeInfo { + isSelecting: boolean; selectedStartTime: Date | null; selectedEndTime: Date | null; doneStartTime: Date | null; @@ -101,6 +106,7 @@ export const useSelectTime = (): TimeInfo => { ); return { + isSelecting: state.isSelecting, selectedStartTime: state.selectedTime.startTime, selectedEndTime: state.selectedTime.endTime, doneStartTime: state.doneTime.startTime, diff --git a/frontend/packages/core/src/types/defaultProps.ts b/frontend/packages/core/src/types/defaultProps.ts new file mode 100644 index 00000000..1a46452a --- /dev/null +++ b/frontend/packages/core/src/types/defaultProps.ts @@ -0,0 +1,6 @@ +import type { CSSProperties, PropsWithChildren } from 'react'; + +export interface DefaultProps extends PropsWithChildren { + className?: string; + style?: CSSProperties; +} \ No newline at end of file diff --git a/frontend/packages/core/src/types/index.ts b/frontend/packages/core/src/types/index.ts new file mode 100644 index 00000000..2d11f02e --- /dev/null +++ b/frontend/packages/core/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './defaultProps'; +export * from './polymorphism'; \ No newline at end of file diff --git a/frontend/src/types/polymorphism.ts b/frontend/packages/core/src/types/polymorphism.ts similarity index 100% rename from frontend/src/types/polymorphism.ts rename to frontend/packages/core/src/types/polymorphism.ts diff --git a/frontend/src/utils/clsx/index.ts b/frontend/packages/core/src/utils/clsx/index.ts similarity index 100% rename from frontend/src/utils/clsx/index.ts rename to frontend/packages/core/src/utils/clsx/index.ts diff --git a/frontend/packages/core/src/utils/common/index.ts b/frontend/packages/core/src/utils/common/index.ts new file mode 100644 index 00000000..28e185d8 --- /dev/null +++ b/frontend/packages/core/src/utils/common/index.ts @@ -0,0 +1,6 @@ +export const isObjectEmpty = (obj: object) => Object.keys(obj).length === 0; + +export const isArrayDifferent = (arr1: unknown[] = [], arr2: unknown[] = []) => { + if (arr1.length !== arr2.length) return true; + return arr1.some((item, index) => !Object.is(item, arr2[index])); +}; \ No newline at end of file diff --git a/frontend/packages/core/src/utils/context/createStateContext.tsx b/frontend/packages/core/src/utils/context/createStateContext.tsx new file mode 100644 index 00000000..36362e13 --- /dev/null +++ b/frontend/packages/core/src/utils/context/createStateContext.tsx @@ -0,0 +1,32 @@ +import { useSafeContext } from '@endolphin/core/hooks'; +import type { PropsWithChildren } from 'react'; +import { createContext, useContext } from 'react'; + +export interface StateProviderProps<Value, ExtraProps> extends PropsWithChildren { + initialValue?: Value; + extraProp?: ExtraProps; +} + +export const createStateContext = <Value, State, ExtraProps>( + useValue?: (init?: Value) => State, +) => { + const StateContext = createContext<(State & Partial<ExtraProps>) | null>(null); + + const StateProvider = ( + { initialValue, extraProp, children }: StateProviderProps<Value, ExtraProps>, + ) => { + const value = (useValue?.(initialValue) || initialValue) as State; + + return ( + <StateContext.Provider value={{ ...value, ...extraProp }}> + {children} + </StateContext.Provider> + ); + }; + + const useContextState = () => useSafeContext(StateContext); + + const useUnsafeContextState = () => useContext(StateContext); + + return { StateProvider, useContextState, useUnsafeContextState }; +}; \ No newline at end of file diff --git a/frontend/packages/core/src/utils/context/index.ts b/frontend/packages/core/src/utils/context/index.ts new file mode 100644 index 00000000..c31105bd --- /dev/null +++ b/frontend/packages/core/src/utils/context/index.ts @@ -0,0 +1 @@ +export * from './createStateContext'; \ No newline at end of file diff --git a/frontend/src/utils/date/calendar/index.ts b/frontend/packages/core/src/utils/date/calendar/index.ts similarity index 100% rename from frontend/src/utils/date/calendar/index.ts rename to frontend/packages/core/src/utils/date/calendar/index.ts diff --git a/frontend/src/utils/date/date.test.ts b/frontend/packages/core/src/utils/date/date.test.ts similarity index 98% rename from frontend/src/utils/date/date.test.ts rename to frontend/packages/core/src/utils/date/date.test.ts index 6c564762..a30ab7ff 100644 --- a/frontend/src/utils/date/date.test.ts +++ b/frontend/packages/core/src/utils/date/date.test.ts @@ -1,4 +1,4 @@ -import { WEEK_MAP } from '@/constants/date'; +import { WEEK_MAP } from '@constants/date'; import { formatDateToWeek } from '.'; diff --git a/frontend/src/utils/date/date.ts b/frontend/packages/core/src/utils/date/date.ts similarity index 95% rename from frontend/src/utils/date/date.ts rename to frontend/packages/core/src/utils/date/date.ts index 545c44e4..380c4aa3 100644 --- a/frontend/src/utils/date/date.ts +++ b/frontend/packages/core/src/utils/date/date.ts @@ -1,5 +1,6 @@ -import { WEEK_MAP } from '@/constants/date'; +import { WEEK_MAP } from '@constants/date'; +import type { Time } from './time'; import { getTimeParts, HOUR_IN_MILLISECONDS, MINUTE_IN_MILLISECONDS } from './time'; export const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; @@ -274,10 +275,19 @@ export const isNextWeek = (start: Date, end: Date) => { return false; }; -export const setDateOnly = (baseDate: Date, newDate: Date) => { +export const setDateOnly = (baseDate: Date, newDate: Date | null) => { + if (!newDate) return baseDate; + const updatedDate = new Date(baseDate); updatedDate.setFullYear(newDate.getFullYear()); updatedDate.setMonth(newDate.getMonth()); updatedDate.setDate(newDate.getDate()); return updatedDate; +}; + +export const setTimeOnly = (baseDate: Date, newTime: Time) => { + const updatedDate = new Date(baseDate); + updatedDate.setHours(newTime.hour); + updatedDate.setMinutes(newTime.minute); + return updatedDate; }; \ No newline at end of file diff --git a/frontend/src/utils/date/format.ts b/frontend/packages/core/src/utils/date/format.ts similarity index 91% rename from frontend/src/utils/date/format.ts rename to frontend/packages/core/src/utils/date/format.ts index 0a741ad4..bcac37ba 100644 --- a/frontend/src/utils/date/format.ts +++ b/frontend/packages/core/src/utils/date/format.ts @@ -1,4 +1,5 @@ import { getDayDiff, getYearMonthDay } from './date'; +import type { Time } from './time'; import { HOUR_IN_MINUTES } from './time'; /** @@ -116,4 +117,14 @@ export const formatTimeToDeadlineString = ({ days, hours, minutes }: { if (days !== 0) return `${Math.abs(days)}일`; if (hours !== 0) return `${Math.abs(hours)}시간`; return `${Math.abs(minutes)}분`; +}; + +export const formatTimeToString = (time: Time): string => { + const { hour, minute } = time; + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; +}; + +export const formatTimeToNumber = (time: Time): number => { + const { hour, minute } = time; + return hour * HOUR_IN_MINUTES + minute; }; \ No newline at end of file diff --git a/frontend/packages/core/src/utils/date/index.ts b/frontend/packages/core/src/utils/date/index.ts new file mode 100644 index 00000000..273f42ea --- /dev/null +++ b/frontend/packages/core/src/utils/date/index.ts @@ -0,0 +1,5 @@ +export * from './calendar'; +export * from './date'; +export * from './format'; +export * from './position'; +export * from './time'; diff --git a/frontend/packages/core/src/utils/date/position.ts b/frontend/packages/core/src/utils/date/position.ts new file mode 100644 index 00000000..02cbe90f --- /dev/null +++ b/frontend/packages/core/src/utils/date/position.ts @@ -0,0 +1,48 @@ +import { TIME_HEIGHT } from '@constants/date'; + +import { getDateParts, setDateOnly } from './date'; + +interface DateRange { + start: Date; + end: Date; +} + +export const calcPositionByDate = (date: Date | null) => { + if (!date) return { x: 0, y: 0 }; + + const day = date.getDay(); + const hour = date.getHours(); + const minute = date.getMinutes(); + + const height = TIME_HEIGHT * hour + TIME_HEIGHT * (minute / 60); + + return { x: day, y: height }; +}; + +// TODO: 테스트 코드 작성 +/** + * + * @param targetDare + * @param targetDate.start - 계산할 범위의 시작 날짜 + * @param targetDate.end - 계산할 범위의 끝 날짜 + * @param selectedWeek - 현재 렌더링된 주의 날짜 배열 + * @returns + */ +export const calcSizeByDate = ({ start, end }: DateRange, selectedWeek: Date[]) => { + const { year: syear, month: sm, day: sd } = getDateParts(selectedWeek[0]); + const { year: eyear, month: em, day: ed } = getDateParts(selectedWeek[6]); + + const firstDayOfWeek = new Date(syear, sm, sd, 0, 0, 0); + const lastDayOfWeek = new Date(eyear, em, ed, 23, 59, 59); + + const startDate = start > firstDayOfWeek ? start : setDateOnly(start, firstDayOfWeek); + const endDate = end < lastDayOfWeek ? end : setDateOnly(end, lastDayOfWeek); + + const { x: sx, y: sy } = calcPositionByDate(startDate); + const { x: ex, y: ey } = calcPositionByDate(endDate); + + const dayDiff = ex - sx; + const height = ey - sy; + + return { width: dayDiff + 1, height, x: sx, y: sy }; +}; \ No newline at end of file diff --git a/frontend/packages/core/src/utils/date/time.ts b/frontend/packages/core/src/utils/date/time.ts new file mode 100644 index 00000000..b5a2cdd0 --- /dev/null +++ b/frontend/packages/core/src/utils/date/time.ts @@ -0,0 +1,73 @@ +export const HOUR_IN_MINUTES = 60; +export const HOUR_IN_MILLISECONDS = 1000 * 60 * 60; +export const MINUTE_IN_MILLISECONDS = 60000; +export const SECOND_IN_MILLISECONDS = 1000; + +export const getTimeRangeString = (startDate: Time, endDate: Time): string => { + const convertTime = (time: Time): string => { + const { hour, minute } = time; + const period = hour >= 12 ? '오후' : '오전'; + const hour12 = hour % 12 || 12; + const paddedMinutes = minute.toString().padStart(2, '0'); + return minute === 0 ? `${period} ${hour12}시` : `${period} ${hour12}시 ${paddedMinutes}분`; + }; + + const startTime = convertTime(startDate); + const endTime = convertTime(endDate); + + return `${startTime} ~ ${endTime}`; +}; + +export const getMinuteDiff = (startTime: Date, endTime: Date): number => { + const MINUTE_IN_MILLISECONDS = 60000; + const diff = endTime.getTime() - startTime.getTime(); + return Math.floor(diff / MINUTE_IN_MILLISECONDS); +}; + +export const getTimeDiffString = ( + startTime: Date, + endTime: Date, + ignoreDateDiff = true, +): string => { + const getTotalMinutes = (date: Date): number => + date.getHours() * HOUR_IN_MINUTES + date.getMinutes(); + + const totalMinutes = ignoreDateDiff + ? getTotalMinutes(endTime) - getTotalMinutes(startTime) + : Math.floor((endTime.getTime() - startTime.getTime()) / MINUTE_IN_MILLISECONDS); + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + const formattedHours = hours > 0 ? `${hours}시간` : ''; + const formattedMinutes = minutes > 0 ? `${minutes}분` : ''; + + // 빈 문자열 제거 + const timeParts = [formattedHours, formattedMinutes].filter(Boolean); + + return timeParts.join(' ') || '0분'; +}; + +export const getTimeParts = (date: Date): Time => { + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + return { hour, minute, second }; +}; + +export interface Time { + hour: number; + minute: number; + second?: number; +} + +export const parseTime = (timeStr: string): Time => { + const parts = timeStr.trim().split(':'); + + const [hourStr, minuteStr, secondStr = '0'] = parts; + const hour = Number(hourStr); + const minute = Number(minuteStr); + const second = Number(secondStr); + + return { hour, minute, second }; +}; diff --git a/frontend/packages/core/src/utils/index.ts b/frontend/packages/core/src/utils/index.ts new file mode 100644 index 00000000..ed1f3473 --- /dev/null +++ b/frontend/packages/core/src/utils/index.ts @@ -0,0 +1,5 @@ +export { default as clsx } from './clsx'; +export * from './common'; +export * from './context'; +export * from './date'; +export * from './jsx'; \ No newline at end of file diff --git a/frontend/src/utils/jsx/index.tsx b/frontend/packages/core/src/utils/jsx/index.tsx similarity index 80% rename from frontend/src/utils/jsx/index.tsx rename to frontend/packages/core/src/utils/jsx/index.tsx index 5ca0ea70..81dcbb39 100644 --- a/frontend/src/utils/jsx/index.tsx +++ b/frontend/packages/core/src/utils/jsx/index.tsx @@ -1,5 +1,4 @@ -import type { ReactNode } from '@tanstack/react-router'; -import type { JSX } from 'react'; +import type { JSX, ReactNode } from 'react'; export const intersperseElement = ( childElements: ReactNode[], diff --git a/frontend/packages/core/tsconfig.json b/frontend/packages/core/tsconfig.json new file mode 100644 index 00000000..6e407cfe --- /dev/null +++ b/frontend/packages/core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "types": ["vitest/globals"], + "paths": { + "@/*": ["src/*"], + "@constants/*": ["src/constants/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + }, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + + /* Module Resolution */ + "composite": true, + "outDir": "./dist", + }, + "include": ["src"], +} \ No newline at end of file diff --git a/frontend/packages/core/vitest.config.ts b/frontend/packages/core/vitest.config.ts new file mode 100644 index 00000000..faae7211 --- /dev/null +++ b/frontend/packages/core/vitest.config.ts @@ -0,0 +1,17 @@ +import { createAlias, reactConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + reactConfig, + defineProject({ + root: dirname, + resolve: { + alias: createAlias(dirname), + }, + }), +); \ No newline at end of file diff --git a/frontend/packages/date-time/__tests__/group.test.ts b/frontend/packages/date-time/__tests__/group.test.ts new file mode 100644 index 00000000..8da2269f --- /dev/null +++ b/frontend/packages/date-time/__tests__/group.test.ts @@ -0,0 +1,32 @@ +import { groupByDate, groupByOverlap } from '@/group'; + +import { DAY_GROUP_CASE, NON_OVERLAPPING_CASE, OVERLAPPING_CASE } from './mocks'; +import { deepCompareGroups, deepCompareOverlappedDates } from './utils'; + +describe('groupByOverlap', () => { + it('겹치지 않는 항목들을 각 그룹으로 분리', () => { + const input = NON_OVERLAPPING_CASE.input; + + const result = groupByOverlap(input); + + expect(deepCompareOverlappedDates(result, NON_OVERLAPPING_CASE.expected)).toBe(true); + }); + + it('겹치는 항목들을 같은 그룹으로 병합', () => { + const input = OVERLAPPING_CASE.input; + + const result = groupByOverlap(input); + + expect(deepCompareOverlappedDates(result, OVERLAPPING_CASE.expected)).toBe(true); + }); +}); + +describe('groupByDate', () => { + it('날짜별로 그룹화된 객체 배열을 반환', () => { + const input = DAY_GROUP_CASE.input; + + const result = groupByDate(input); + + expect(deepCompareGroups(result, DAY_GROUP_CASE.expected)).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/packages/date-time/__tests__/mocks/index.ts b/frontend/packages/date-time/__tests__/mocks/index.ts new file mode 100644 index 00000000..3865f410 --- /dev/null +++ b/frontend/packages/date-time/__tests__/mocks/index.ts @@ -0,0 +1,167 @@ +import EndolphinDate from '@/date'; +import type { DateRangeWithId, GroupInfo, OverlapDate, ReturnItem } from '@/group'; +import type { DateRange } from '@/type'; + +export const NON_OVERLAPPING_CASE: { + input: DateRangeWithId[]; + expected: OverlapDate<DateRangeWithId>[]; +} = { + input: [ + { id: 'a', start: new EndolphinDate('2023-01-01'), end: new EndolphinDate('2023-01-02') }, + { id: 'b', start: new EndolphinDate('2023-01-03'), end: new EndolphinDate('2023-01-04') }, + { id: 'c', start: new EndolphinDate('2023-01-05'), end: new EndolphinDate('2023-01-06') }, + ], + expected: [ + { + id: 'a', + start: new EndolphinDate('2023-01-01'), + end: new EndolphinDate('2023-01-02'), idx: 0, + }, + { + id: 'b', + start: new EndolphinDate('2023-01-03'), + end: new EndolphinDate('2023-01-04'), idx: 0, + }, + { + id: 'c', + start: new EndolphinDate('2023-01-05'), + end: new EndolphinDate('2023-01-06'), idx: 0, + }, + ], +}; + +export const OVERLAPPING_CASE: { + input: DateRangeWithId[]; + expected: OverlapDate<DateRangeWithId>[]; +} = { + input: [ + { id: 'a', start: new EndolphinDate('2023-01-01'), end: new EndolphinDate('2023-01-05') }, + { id: 'b', start: new EndolphinDate('2023-01-03'), end: new EndolphinDate('2023-01-07') }, + { id: 'c', start: new EndolphinDate('2023-01-08'), end: new EndolphinDate('2023-01-10') }, + { id: 'd', start: new EndolphinDate('2023-01-09'), end: new EndolphinDate('2023-01-12') }, + ], + expected: [ + { + id: 'a', + start: new EndolphinDate('2023-01-01'), + end: new EndolphinDate('2023-01-05'), + idx: 0, + }, + { + id: 'b', + start: new EndolphinDate('2023-01-03'), + end: new EndolphinDate('2023-01-07'), + idx: 1, + }, + { + id: 'c', + start: new EndolphinDate('2023-01-08'), + end: new EndolphinDate('2023-01-10'), + idx: 0, + }, + { + id: 'd', + start: new EndolphinDate('2023-01-09'), + end: new EndolphinDate('2023-01-12'), + idx: 1, + }, + ], +}; + +export const DAY_GROUP_CASE: { + input: GroupInfo<null>[]; + expected: ReturnItem<null>[][]; +} = { + input: [ + { + id: '1', + data: null, + start: new EndolphinDate(2023, 1, 1, 4, 0), + end: new EndolphinDate(2023, 1, 2, 1, 0), + sy: 2023, + sm: 1, + sd: 1, + ey: 2023, + em: 1, + ed: 2, + }, + { + id: '2', + data: null, + start: new EndolphinDate(2023, 1, 2, 23, 0), + end: new EndolphinDate(2023, 1, 3, 1, 0), + sy: 2023, + sm: 1, + sd: 2, + ey: 2023, + em: 1, + ed: 3, + }, + { + id: '3', + data: null, + start: new EndolphinDate(2023, 1, 1, 10, 0), + end: new EndolphinDate(2023, 1, 1, 11, 0), + sy: 2023, + sm: 1, + sd: 1, + ey: 2023, + em: 1, + ed: 1, + }, + ], + expected: [ + [ + { + id: '1', + data: null, + start: new EndolphinDate(2023, 1, 1, 4, 0), + end: new EndolphinDate(2023, 1, 1, 23, 59), + }, + { + id: '3', + data: null, + start: new EndolphinDate(2023, 1, 1, 10, 0), + end: new EndolphinDate(2023, 1, 1, 11, 0), + }, + ], + [ + { + id: '1', + data: null, + start: new EndolphinDate(2023, 1, 2, 0, 0), + end: new EndolphinDate(2023, 1, 2, 1, 0), + }, + { + id: '2', + data: null, + start: new EndolphinDate(2023, 1, 2, 23, 0), + end: new EndolphinDate(2023, 1, 2, 23, 59), + }, + ], + [ + { + id: '2', + data: null, + start: new EndolphinDate(2023, 1, 3, 0, 0), + end: new EndolphinDate(2023, 1, 3, 1, 0), + }, + ], + ], +}; + +export const SORT_CASE: { + input: DateRange[]; + expected: DateRange[]; +} = { + input: [ + { start: new EndolphinDate('2023-01-02'), end: new EndolphinDate('2023-01-03') }, + { start: new EndolphinDate('2023-01-01'), end: new EndolphinDate('2023-01-02') }, + { start: new EndolphinDate('2023-01-03'), end: new EndolphinDate('2023-01-04') }, + ], + expected: [ + { start: new EndolphinDate('2023-01-01'), end: new EndolphinDate('2023-01-02') }, + { start: new EndolphinDate('2023-01-02'), end: new EndolphinDate('2023-01-03') }, + { start: new EndolphinDate('2023-01-03'), end: new EndolphinDate('2023-01-04') }, + ], +}; \ No newline at end of file diff --git a/frontend/packages/date-time/__tests__/sort.test.ts b/frontend/packages/date-time/__tests__/sort.test.ts new file mode 100644 index 00000000..c2de6026 --- /dev/null +++ b/frontend/packages/date-time/__tests__/sort.test.ts @@ -0,0 +1,31 @@ +import EndolphinDate from '@/date'; +import { sortDates } from '@/sort'; +import type { DateRange } from '@/type'; + +import { SORT_CASE } from './mocks'; +import { deepCompareDateRanges } from './utils'; + +describe('sortDates', () => { + it('날짜 배열 오름차순 정렬', () => { + // given + const dates = SORT_CASE.input; + + // when + const sorted = dates.sort(sortDates); + + // then + expect(deepCompareDateRanges(SORT_CASE.expected, sorted)).toBe(true); + }); + + it('단일 날짜 처리', () => { + // given + const dates: DateRange[] + = [{ start: new EndolphinDate('2023-01-02'), end: new EndolphinDate('2023-01-03') }]; + + // when + const sorted = dates.sort(sortDates); + + // then + expect(deepCompareDateRanges(dates, sorted)).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/packages/date-time/__tests__/utils/index.ts b/frontend/packages/date-time/__tests__/utils/index.ts new file mode 100644 index 00000000..49e648ad --- /dev/null +++ b/frontend/packages/date-time/__tests__/utils/index.ts @@ -0,0 +1,50 @@ +import type { OverlapDate, ReturnItem } from '@/group'; +import type { DateRange } from '@/type'; + +export const deepCompareDateRanges = ( + a: DateRange[], + b: DateRange[], +): boolean => { + if (a.length !== b.length) return false; + + return a.every(({ start: as, end: ae }, index) => { + const { start: bs, end: be } = b[index]; + return as.formatDateToBarString() === bs.formatDateToBarString() + && ae.formatDateToBarString() === be.formatDateToBarString(); + }); +}; + +// TODO: diff 체크를 쉽게 하기 위해 커스텀 설정 추가하기 +// 또는 중첩된 객체 비교 쉽게 하는법..? 순서 상관 없이. +export const deepCompareGroups = ( + a: ReturnItem<null>[][], + b: ReturnItem<null>[][], +): boolean => { + if (a.length !== b.length) return false; + + return a.every((groupA, index) => { + const groupB = b[index]; + if (groupA.length !== groupB.length) return false; + + return groupA.every((itemA, itemIndex) => { + const itemB = groupB[itemIndex]; + return itemA.id === itemB.id + && itemA.start.formatDateToBarString() === itemB.start.formatDateToBarString() + && itemA.end.formatDateToBarString() === itemB.end.formatDateToBarString(); + }); + }); +}; + +export const deepCompareOverlappedDates = ( + a: OverlapDate<DateRange>[], + b: OverlapDate<DateRange>[], +): boolean => { + if (a.length !== b.length) return false; + + return a.every(({ start: as, end: ae, idx: aidx }, index) => { + const { start: bs, end: be, idx: bidx } = b[index]; + return aidx === bidx + && as.formatDateToBarString() === bs.formatDateToBarString() + && ae.formatDateToBarString() === be.formatDateToBarString(); + }); +}; \ No newline at end of file diff --git a/frontend/packages/date-time/package.json b/frontend/packages/date-time/package.json new file mode 100644 index 00000000..e51e8913 --- /dev/null +++ b/frontend/packages/date-time/package.json @@ -0,0 +1,22 @@ +{ + "name": "@endolphin/date-time", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "test": "vitest" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@endolphin/tsup-config": "workspace:^", + "@endolphin/vitest-config": "workspace:^" + } +} diff --git a/frontend/packages/date-time/src/constants/date.ts b/frontend/packages/date-time/src/constants/date.ts new file mode 100644 index 00000000..1fb538df --- /dev/null +++ b/frontend/packages/date-time/src/constants/date.ts @@ -0,0 +1,32 @@ +export type Time = number | 'all' | 'empty'; + +export type WEEKDAY = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT'; + +export const TIMES: readonly number[] = Object.freeze(new Array(24).fill(0) + .map((_, i) => i)); + +export const WEEK: readonly WEEKDAY[] = Object.freeze([ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]); + +export const WEEK_MAP: Record<string, string> = Object.freeze({ + 1: '첫째주', + 2: '둘째주', + 3: '셋째주', + 4: '넷째주', + 5: '다섯째주', +}); + +export const MINUTES = Object.freeze(new Array(4).fill(0) + .map((_, i) => i * 15)); + +export const MINUTES_HALF = (totalTime: number, startTime: number) => + Object.freeze(new Array(totalTime * 2).fill(0) + .map((_, i) => startTime + i * 30)); +export const TIME_HEIGHT = 66; diff --git a/frontend/packages/date-time/src/constants/regex.ts b/frontend/packages/date-time/src/constants/regex.ts new file mode 100644 index 00000000..173887d2 --- /dev/null +++ b/frontend/packages/date-time/src/constants/regex.ts @@ -0,0 +1,4 @@ +export const DATE_BAR = /^\d{4}-\d{2}-\d{2}$/; +export const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+Z)?$/; +export const TIME = /^\d{2}:\d{2}$/; +export const PASSWORD = /^[0-9]{4,6}$/; \ No newline at end of file diff --git a/frontend/packages/date-time/src/date.ts b/frontend/packages/date-time/src/date.ts new file mode 100644 index 00000000..69baed09 --- /dev/null +++ b/frontend/packages/date-time/src/date.ts @@ -0,0 +1,91 @@ +import { DATE_BAR, DATETIME } from '@constants/regex'; +import * as date from '@utils/date'; +import * as format from '@utils/format'; + +import type { InputDate } from './type'; + +export default class EndolphinDate { + #date; + + constructor (input: InputDate); + constructor (year: number, month: number, day: number, hour?: number, minute?: number); + + constructor (...args: [InputDate] | [number, number, number, number?, number?]) { + if (args.length === 1) { + const [input] = args; + this.#date = this.#formateToDate(input); + } else { + const [year, month, day, hour = 0, minute = 0] = args; + this.#date = new Date(year, month, day, hour, minute); + } + } + + #formateToDate (input: InputDate): Date | null { + if (!input) return null; + if (input instanceof Date) return input; + if (typeof input === 'number') { + return new Date(input); + } + if (DATE_BAR.test(input) || DATETIME.test(input)) return new Date(input); + + throw new Error('올바르지 않은 날짜 형식입니다.'); + } + + formatDateToBarString () { + return format.formatDateToBarString(this.#date); + } + + formatDateToDotString () { + return format.formatDateToDotString(this.#date); + } + + formatDateToDateTimeString () { + return format.formatDateToDateTimeString(this.#date); + } + + formatTimeToColonString () { + return format.formatTimeToColonString(this.#date); + } + + formatDateToTimeString () { + return format.formatDateToTimeString(this.#date); + } + + formatDateToString () { + return format.formatDateToString(this.#date); + } + + formatDowString () { + if (!this.#date) return ''; + return format.getDowString(this.#date); + } + + formatDateToDdayString () { + if (!this.#date) return ''; + return format.formatDateToDdayString(this.#date); + } + + getDate () { + return this.#date; + } + + getDateParts () { + if (!this.#date) return { year: 0, month: 0, day: 0 }; + const { year, month, day } = date.getDateParts(this.#date); + return { year, month, day }; + } + + valueOf () { + if (!this.#date) return NaN; + return this.#date.getTime(); + } + + toString () { + return format.formatDateToBarString(this.#date); + } + + [Symbol.toPrimitive] (hint: string) { + if (hint === 'number') return this.valueOf(); + return this.toString(); + } +} \ No newline at end of file diff --git a/frontend/packages/date-time/src/group.ts b/frontend/packages/date-time/src/group.ts new file mode 100644 index 00000000..80cb2380 --- /dev/null +++ b/frontend/packages/date-time/src/group.ts @@ -0,0 +1,83 @@ +import EndolphinDate from './date'; +import type { DateRange } from './type'; + +export type DateRangeWithId = DateRange & { + id: string; +}; + +export type OverlapDate<T> = T & { + idx: number; +}; + +export interface GroupInfo<T> { + id: string; + data: T; + start: EndolphinDate; + end: EndolphinDate; + sy: number; + sm: number; + sd: number; + ey: number; + em: number; + ed: number; +} + +export type ReturnItem<T> = DateRangeWithId & { + data: T; +}; + +/** + * + * @param dateInfos - 날짜 범위와 ID를 포함하는 객체 배열 + * @returns - 겹치는 날짜 범위를 그룹화하여 반환 + */ +export const groupByOverlap = <T extends DateRangeWithId>(dateInfos: T[]): OverlapDate<T>[] => { + const groups = [] as OverlapDate<T>[][]; + + for (const current of dateInfos) { + const lastGroup = groups[groups.length - 1]; + const isNewGroup = + !lastGroup || lastGroup[lastGroup.length - 1].end <= current.start; + + if (isNewGroup) groups.push([{ ...current, idx: 0 }]); + else lastGroup.push({ ...current, idx: lastGroup.length }); + } + + return groups.flat(); +}; + +/** + * + * @param infos - 날짜 범위와 ID, 시작 및 종료 날짜를 포함하는 객체 배열 + * @returns - 날짜별로 그룹화된 객체 배열 + */ +export const groupByDate = <T>(infos: GroupInfo<T>[]): ReturnItem<T>[][] => { + const group = {} as Record<number, ReturnItem<T>[]>; + + const add = (key: number, content: ReturnItem<T>) => { + if (!group[key]) group[key] = []; + group[key].push(content); + return group; + }; + + const result = infos.reduce((acc, info) => { + const { id, data, start, end, sy, sm, sd, ey, em, ed } = info; + const startDate = start.getDate(); + const endDate = end.getDate(); + + if (!startDate || !endDate) return acc; + + const startDay = startDate.getDay(); + const endDay = endDate.getDay(); + + if (sd !== ed) { + add(startDay, { id, data, start, end: new EndolphinDate(sy, sm, sd, 23, 59) }); + add(endDay, { id, data, start: new EndolphinDate(ey, em, ed, 0, 0), end }); + return acc; + } + + return add(startDay, { id, data, start, end }); + }, group); + + return Object.values(result); +}; \ No newline at end of file diff --git a/frontend/packages/date-time/src/index.ts b/frontend/packages/date-time/src/index.ts new file mode 100644 index 00000000..595e3283 --- /dev/null +++ b/frontend/packages/date-time/src/index.ts @@ -0,0 +1,4 @@ +export { default as EndolphinDate } from './date'; +export * from './group'; +export * from './sort'; +export { default as EndolphinTime } from './time'; \ No newline at end of file diff --git a/frontend/packages/date-time/src/sort.ts b/frontend/packages/date-time/src/sort.ts new file mode 100644 index 00000000..111bc73c --- /dev/null +++ b/frontend/packages/date-time/src/sort.ts @@ -0,0 +1,16 @@ +import type { DateRange } from './type'; + +/** + * + * @param d1 - 첫 번째 날짜 범위 + * @param d2 - 두 번째 날짜 범위 + * @returns - 날짜 범위를 비교하여 정렬 순서를 반환합니다. + */ +export const sortDates = (d1: DateRange, d2: DateRange) => { + if (d1.start < d2.start) return -1; + if (d1.start > d2.start) return 1; + if (d1.end < d2.end) return -1; + if (d1.end > d2.end) return 1; + + return 0; +}; \ No newline at end of file diff --git a/frontend/packages/date-time/src/time.ts b/frontend/packages/date-time/src/time.ts new file mode 100644 index 00000000..15542c4b --- /dev/null +++ b/frontend/packages/date-time/src/time.ts @@ -0,0 +1,71 @@ +import { TIME } from '@constants/regex'; +import * as format from '@utils/format'; +import * as calc from '@utils/time'; + +import EndolphinDate from './date'; +import type { InputTime, Time } from './type'; + +export default class EndolphinTime { + #time; + #milliseconds; + + #HOUR = 60 * 60 * 1000; + #MINUTE = 60 * 1000; + #SECOND = 1000; + + constructor (input: InputTime) { + this.#time = this.#formatToTime(input); + this.#milliseconds = this.#formatToMilliSeconds(this.#time); + } + + #formatToTime (input: InputTime): Time | null { + if (!input) return null; + if (input instanceof Date) return calc.getTimeParts(input); + if (input instanceof EndolphinDate) { + const date = input.getDate(); + if (!date) return null; + return calc.getTimeParts(date); + } + if (TIME.test(input)) return calc.parseTime(input); + + throw new Error('올바르지 않은 시간 형식입니다.'); + } + + #formatToMilliSeconds (time: Time | null): number { + if (!time) return 0; + + const { hour, minute, second } = time; + return hour * this.#HOUR + minute * this.#MINUTE + second * this.#SECOND; + } + + formatTimeToString () { + if (!this.#time) return ''; + return format.formatTimeToString(this.#time); + } + + formatTimeToNumber () { + if (!this.#time) return ''; + return format.formatTimeToNumber(this.#time); + } + + getTime () { + return this.#time; + } + + getMilliseconds () { + return this.#milliseconds; + } + + getMinuteDiff (time: EndolphinTime): number { + const MINUTE_IN_MILLISECONDS = 60000; + const diff = this.#milliseconds - time.getMilliseconds(); + return Math.floor(diff / MINUTE_IN_MILLISECONDS); + } + + getTimeRangeString (time: EndolphinTime): string { + const endTime = time.getTime(); + if (!this.#time || !endTime) return ''; + + return calc.getTimeRangeString(this.#time, endTime); + } +} \ No newline at end of file diff --git a/frontend/packages/date-time/src/type.ts b/frontend/packages/date-time/src/type.ts new file mode 100644 index 00000000..fb8fe66d --- /dev/null +++ b/frontend/packages/date-time/src/type.ts @@ -0,0 +1,16 @@ +import type EndolphinDate from './date'; + +export type InputDate = Date | string | number; + +export type InputTime = Date | EndolphinDate | string; + +export interface Time { + hour: number; + minute: number; + second: number; +} + +export interface DateRange { + start: EndolphinDate; + end: EndolphinDate; +}; \ No newline at end of file diff --git a/frontend/packages/date-time/src/utils/date.ts b/frontend/packages/date-time/src/utils/date.ts new file mode 100644 index 00000000..d4f22d08 --- /dev/null +++ b/frontend/packages/date-time/src/utils/date.ts @@ -0,0 +1,295 @@ +import { WEEK_MAP } from '@constants/date'; + +import type { Time } from './time'; +import { getTimeParts, HOUR_IN_MILLISECONDS, MINUTE_IN_MILLISECONDS } from './time'; + +export const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +const SUNDAY_CODE = 0; +const SATURDAY_CODE = 6; + +/** + * 날짜가 해당 달의 첫째주에 포함되는지를 판단합니다. + * @param date - 날짜 객체. + * @returns - 해당 달의 첫째주인지 여부. + */ +const isStartWithFirstWeek = (date: Date) => { + const day = date.getDay(); + if (date.getDate() === 1 && (day === 5 || day === 6 || day === 0)) return false; + if (date.getDate() === 2 && (day === 6 || day === 0)) return false; + if (date.getDate() === 3 && day === 0) return false; + return true; +}; + +/** + * 해당 달의 마지막 주에 포함되는지를 판단합니다. + * @param date - 날짜 객체. + * @returns - 해당 달의 마지막 주인지 여부. + */ +const isEndWithLastWeek = (date: Date) => { + const lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0); + const daysAfterWeekFirstDate = (lastDate.getDay() + 6) % 7; + const isLastWeek = lastDate.getDate() - daysAfterWeekFirstDate <= date.getDate(); + + const day = lastDate.getDay(); + if (isLastWeek && (day === 1 || day === 2 || day === 3)) return false; + return true; +}; + +const getWeekNumber = (date: Date) => { + const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); + const offset = isStartWithFirstWeek(firstDayOfMonth) ? firstDayOfMonth.getDay() : 0; + return Math.ceil((date.getDate() - (date.getDay() + 6) % 7 + offset) / 7); +}; + +type DateWeekType = { + year: string; + month: number; + week: keyof typeof WEEK_MAP; +}; +/** + * 날짜가 몇년 몇월 몇 번째 주인지 계산합니다. + * @example formatDateToWeek(new Date(2025, 2, 1)); // 25년 1월 다섯째주 + * @example formatDateToWeek(new Date(2025, 2, 2)); // 25년 2월 첫째주 + * @param date - 날짜 객체. + * @returns - 특정 날짜의 년, 월, 주 정보를 담은 객체. + */ +export const formatDateToWeek = (date: Date): DateWeekType => { + if (!isStartWithFirstWeek(date)) { + const prevMonthLastDate = new Date(date.getFullYear(), date.getMonth(), 0); + return formatDateToWeek(prevMonthLastDate); + } + + if (!isEndWithLastWeek(date)) { + const nextMonthFirstDate = new Date(date.getFullYear(), date.getMonth() + 1, 1); + return { + year: String(nextMonthFirstDate.getFullYear()).slice(2), + month: nextMonthFirstDate.getMonth() + 1, + week: WEEK_MAP[1], + }; + } + + return { + year: String(date.getFullYear()).slice(2), + month: date.getMonth() + 1, + week: WEEK_MAP[getWeekNumber(date)], + }; +}; + +/** + * + * @param date - 날짜 객체. + * @returns - 특정 날짜가 포함된 주의 날짜 객체 배열. + */ +export const formatDateToWeekDates = (date: Date | null): Date[] => { + if (!date) return []; + + const selected = new Date(date); + const firstDateOfWeek = new Date(selected.setDate(selected.getDate() - selected.getDay())); + const dates = new Array(7).fill(0) + .map((_, i)=>{ + const DAY = 60 * 60 * 24 * 1000; + const date = new Date(firstDateOfWeek.getTime() + i * DAY); + return date; + }); + + return dates; +}; + +/** + * + * @param date - 날짜 객체. + * @returns - 특정 날짜가 포함된 주의 첫째 날과 마지막 날의 날짜 객체. + */ + +export const formatDateToWeekRange = (date: Date): { + startDate: Date; + endDate: Date; +} => { + const selected = new Date(date); + const firstDateOfWeek = new Date(selected); + firstDateOfWeek.setDate(firstDateOfWeek.getDate() - selected.getDay()); + + const lastDateOfWeek = new Date(firstDateOfWeek); + lastDateOfWeek.setDate(firstDateOfWeek.getDate() + 6); + + return { startDate: firstDateOfWeek, endDate: lastDateOfWeek }; +}; + +/** + * + * @param date1 - 비교할 날짜1 + * @param date2 - 비교할 날짜2 + * @returns 두 날짜가 같은 날짜인지 여부 + */ +export const isSameDate = (date1: Date, date2: Date): boolean => ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() +); + +export const isWeekend = (date: Date): boolean => + date.getDay() === SUNDAY_CODE || date.getDay() === SATURDAY_CODE; + +/** + * 두 날짜 객체를 비교하여 시작 날짜와 종료 날짜를 반환합니다. + * @param date1 - 비교할 날짜1 + * @param date2 - 비교할 날짜2 + * @returns - 시작 날짜와 종료 날짜 + */ +export const sortDate = (date1: Date | null, date2: Date | null): { + startDate: Date | null; + endDate: Date | null; +} => { + if (!date1 || !date2) { + return { + startDate: date1 || date2, + endDate: date1 || date2, + }; + } + const [startDate, endDate] + = [date1, date2].sort((a, b) => a.getTime() - b.getTime()); + return { startDate, endDate }; +}; + +/** + * + * @param target - 비교할 날짜 + * @param startDate - 시작 날짜 + * @param endDate - 종료 날짜 + * @returns - 날짜가 범위 내에 있는지 여부 + */ +export const isDateInRange = ( + target: Date, + startDate: Date | null, + endDate: Date | null, +): boolean => { + if (!startDate || !endDate) return false; + + const { startDate: start, endDate: end } = sortDate(startDate, endDate); + if (!start || !end) return false; + + const targetTime = target.getTime(); + const startTime = start.getTime(); + const endTime = end.getTime(); + + return targetTime >= startTime && targetTime <= endTime; +}; + +export const isSaturday = (date: Date | null): boolean => { + if (!date) return false; + return date.getDay() === SATURDAY_CODE; +}; + +export const isSunday = (date: Date): boolean => date.getDay() === SUNDAY_CODE; +// TODO: 공휴일 OPEN API에 연결 +// export const isHoliday = (date: Date): boolean => false; + +export const getDateParts = (date: Date) => ({ + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), +}); + +/** + * 날짜 객체를 year, month, day로 분리합니다. + * @param date - 날짜 객체 + * @returns { year, month, day } 형태의 객체 + */ +export const getYearMonthDay = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { year, month, day }; +}; + +export const isAllday = ( + startDate: string | Date | null, + endDate: string | Date | null, +): boolean => { + if (!startDate || !endDate) return false; + + return new Date(endDate).getTime() - new Date(startDate).getTime() >= DAY_IN_MILLISECONDS; +}; + +export const getDateRangeString = (startDate: Date, endDate: Date): string => { + const { year: startY, month: startM, day: startD } = getDateParts(startDate); + const { year: endY, month: endM, day: endD } = getDateParts(endDate); + + const isSameYear = startY !== endY; + const format = (year: number, month: number, day: number): string => + isSameYear ? `${year}년 ${month + 1}월 ${day}일` : `${month + 1}월 ${day}일`; + + return `${format(startY, startM, startD)} ~ ${format(endY, endM, endD)}`; +}; + +export const getDateTimeRangeString = (start: Date, end: Date): string => { + const { month: startMonth, day: startDay } = getYearMonthDay(start); + const { month: endMonth, day: endDay } = getYearMonthDay(end); + + const convertTime = (date: Date): string => { + const { hour, minute } = getTimeParts(date); + const period = hour >= 12 ? '오후' : '오전'; + const hour12 = hour % 12 || 12; + const paddedMinutes = minute.toString().padStart(2, '0'); + return minute === 0 ? `${period} ${hour12}시` : `${period} ${hour12}시 ${paddedMinutes}분`; + }; + + const startTime = convertTime(start); + const endTime = convertTime(end); + + if (startMonth === endMonth && startDay === endDay) { + return `${startMonth}월 ${startDay}일 ${startTime} ~ ${endTime}`; + } + return `${startMonth}월 ${startDay}일 ${startTime} ~ ${endMonth}월 ${endDay}일 ${endTime}`; +}; + +export const getDayDiff = (date: Date): number => { + const today = new Date(); + const todayDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diff = targetDate.getTime() - todayDate.getTime(); + return Math.floor(diff / DAY_IN_MILLISECONDS); +}; + +export const getTimeLeftInfoFromMilliseconds = (milliseconds: number) => { + const days = Math.floor(milliseconds / DAY_IN_MILLISECONDS); + const remainingAfterDays = milliseconds % DAY_IN_MILLISECONDS; + + const hours = Math.floor(remainingAfterDays / HOUR_IN_MILLISECONDS); + const remainingAfterHours = remainingAfterDays % HOUR_IN_MILLISECONDS; + const minutes = Math.floor(remainingAfterHours / MINUTE_IN_MILLISECONDS); + + return { days, hours, minutes }; +}; + +export const isNextWeek = (start: Date, end: Date) => { + const { year: sy, month: sm, day: sd } = getDateParts(start); + const { year: ey, month: em, day: ed } = getDateParts(end); + const startDate = new Date(sy, sm, sd); + const endDate = new Date(ey, em, ed); + + const timeDiff = Math.abs(startDate.getTime() - endDate.getTime()); + + if (timeDiff > 7 * DAY_IN_MILLISECONDS) return true; + if (start.getDay() > end.getDay()) return true; + return false; +}; + +export const setDateOnly = (baseDate: Date, newDate: Date | null) => { + if (!newDate) return baseDate; + + const updatedDate = new Date(baseDate); + updatedDate.setFullYear(newDate.getFullYear()); + updatedDate.setMonth(newDate.getMonth()); + updatedDate.setDate(newDate.getDate()); + return updatedDate; +}; + +export const setTimeOnly = (baseDate: Date, newTime: Time) => { + const updatedDate = new Date(baseDate); + updatedDate.setHours(newTime.hour); + updatedDate.setMinutes(newTime.minute); + return updatedDate; +}; \ No newline at end of file diff --git a/frontend/packages/date-time/src/utils/format.ts b/frontend/packages/date-time/src/utils/format.ts new file mode 100644 index 00000000..bcac37ba --- /dev/null +++ b/frontend/packages/date-time/src/utils/format.ts @@ -0,0 +1,130 @@ +import { getDayDiff, getYearMonthDay } from './date'; +import type { Time } from './time'; +import { HOUR_IN_MINUTES } from './time'; + +/** + * 날짜 객체를 YY-MM-DD 형식의 문자열로 변환합니다. + * @param date - 날짜 객체 + * @returns + */ +export const formatDateToBarString = (date: Date | null): string => { + if (!date) return ''; + const { year, month, day } = getYearMonthDay(date); + return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; +}; + +export const formatDateToDotString = (date: Date | null): string => { + if (!date) return ''; + const { year, month, day } = getYearMonthDay(date); + return `${year}. ${month.toString().padStart(2, '0')}. ${day.toString().padStart(2, '0')}`; +}; + +export const formatDateToDateTimeString = (date: Date | null): string => { + if (!date) return ''; + return `${formatDateToBarString(date)}T${formatDateToTimeString(date)}:00`; +}; + +export const formatTimeToColonString = (date: Date | null): string => { + if (!date) return ''; + const hours = date.getHours(); + const minutes = date.getMinutes(); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; +}; + +export const formatDateToTimeString = (date: Date | null): string => { + if (!date) return ''; + + const hours = date.getHours().toString() + .padStart(2, '0'); + const minutes = date.getMinutes().toString() + .padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + +export const formatMinutesToTimeString = (minutes: number): string => { + const hours = Math.floor(minutes / HOUR_IN_MINUTES); + const restMinutes = (minutes % HOUR_IN_MINUTES); + const minutesString = restMinutes ? ` ${restMinutes.toString().padStart(2, '0')}분` : ''; + const amOrPm = hours >= 12 ? '오후' : '오전'; + + const singleDigitHours = hours > 12 ? hours % 12 : hours; + + return `${amOrPm} ${singleDigitHours}시${minutesString}`; +}; + +export const formatNumberToTimeString = (number: number): string => { + const hours = Math.floor(number / HOUR_IN_MINUTES).toString() + .padStart(2, '0'); + const minutes = (number % HOUR_IN_MINUTES).toString().padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + +export const formatTimeStringToNumber = (timeString: string): number => { + const [hours, minutes] = timeString.split(':'); + return Number(hours) * HOUR_IN_MINUTES + Number(minutes); +}; + +export const formatDateToString = (date: Date | null): string => { + if (!date) return ''; + const { year, month, day } = getYearMonthDay(date); + return `${year.toString().slice(2)}년 ${month}월 ${day}일`; +}; + +export const formatMinutesToTimeDuration = (minutes: number): string => { + const hours = Math.floor(minutes / HOUR_IN_MINUTES); + const restMinutes = (minutes % HOUR_IN_MINUTES); + + if (hours === 0) return `${restMinutes}분`; + if (restMinutes === 0) return `${hours}시간`; + return `${hours}시간 ${restMinutes}분`; +}; + +export const formatMillisecondsToDDay = (milliseconds: number): number => { + const DAY = 1000 * 60 * 60 * 24; + return Math.floor(milliseconds / DAY); +}; + +export const formatBarStringToLocaleString = (dateString: string): string => { + const [year, month, day] = dateString.split('-'); + return `${year.slice(2)}년 ${month}월 ${day}일`; +}; + +export const formatTimeStringToLocaleString = (timeString: string): string => { + const [hours, minutes] = timeString.split(':').map(Number); + if (!minutes) return `${hours}시`; + + return `${hours}시 ${minutes}분`; +}; + +export const formatDateToDdayString = (date: Date): string => { + const diffDays = getDayDiff(date); + + if (diffDays === 0) return 'D-Day'; + if (diffDays > 0) return `D-${diffDays}`; + return `D+${Math.abs(diffDays)}`; +}; + +export const getDowString = (date: Date): string => + date.toLocaleString('ko-KR', { weekday: 'short' }); + +export const formatTimeToDeadlineString = ({ days, hours, minutes }: { + days: number; + hours: number; + minutes: number; +}): string => { + if (days !== 0) return `${Math.abs(days)}일`; + if (hours !== 0) return `${Math.abs(hours)}시간`; + return `${Math.abs(minutes)}분`; +}; + +export const formatTimeToString = (time: Time): string => { + const { hour, minute } = time; + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; +}; + +export const formatTimeToNumber = (time: Time): number => { + const { hour, minute } = time; + return hour * HOUR_IN_MINUTES + minute; +}; \ No newline at end of file diff --git a/frontend/src/utils/date/time.ts b/frontend/packages/date-time/src/utils/time.ts similarity index 82% rename from frontend/src/utils/date/time.ts rename to frontend/packages/date-time/src/utils/time.ts index 3ec013f3..123aea03 100644 --- a/frontend/src/utils/date/time.ts +++ b/frontend/packages/date-time/src/utils/time.ts @@ -1,6 +1,7 @@ export const HOUR_IN_MINUTES = 60; export const HOUR_IN_MILLISECONDS = 1000 * 60 * 60; export const MINUTE_IN_MILLISECONDS = 60000; +export const SECOND_IN_MILLISECONDS = 1000; export const formatDateToTimeString = (date: Date | null): string => { if (!date) return ''; @@ -68,26 +69,16 @@ export const getTimeParts = (date: Date): Time => { export interface Time { hour: number; minute: number; - second?: number; + second: number; } export const parseTime = (timeStr: string): Time => { const parts = timeStr.trim().split(':'); - if (parts.length < 2 || parts.length > 3) { - throw new Error('parseTime: Invalid time format'); - } const [hourStr, minuteStr, secondStr = '0'] = parts; const hour = Number(hourStr); const minute = Number(minuteStr); const second = Number(secondStr); - if (isNaN(hour) || isNaN(minute) || isNaN(second)) { - throw new Error('parseTime: Invalid numeric values in time string'); - } - if (hour < 0 || hour > 23) throw new Error('parseTime: Hour must be between 0 and 23'); - if (minute < 0 || minute > 59) throw new Error('parseTime: Minute must be between 0 and 59'); - if (second < 0 || second > 59) throw new Error('parseTime: Second must be between 0 and 59'); - return { hour, minute, second }; }; diff --git a/frontend/packages/date-time/tsconfig.json b/frontend/packages/date-time/tsconfig.json new file mode 100644 index 00000000..6bffe564 --- /dev/null +++ b/frontend/packages/date-time/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@constants/*": ["src/constants/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@theme/*": ["src/theme/*"] + }, + + /* Module Resolution */ + // "composite": true, + "outDir": "./dist", + "types": ["vitest/globals"], + }, + "include": ["src", "__tests__"], +} diff --git a/frontend/packages/date-time/tsup.config.ts b/frontend/packages/date-time/tsup.config.ts new file mode 100644 index 00000000..bd6b799e --- /dev/null +++ b/frontend/packages/date-time/tsup.config.ts @@ -0,0 +1,6 @@ +import { nodeConfig } from '@endolphin/tsup-config'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + ...nodeConfig, +}); diff --git a/frontend/packages/date-time/vitest.config.ts b/frontend/packages/date-time/vitest.config.ts new file mode 100644 index 00000000..9a484447 --- /dev/null +++ b/frontend/packages/date-time/vitest.config.ts @@ -0,0 +1,20 @@ +import { createAlias, nodeConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + nodeConfig, + defineProject({ + root: dirname, + test: { + include: ['__tests__/*.test.ts'], + }, + resolve: { + alias: createAlias(dirname), + }, + }), +); \ No newline at end of file diff --git a/frontend/packages/theme/assets/fonts/Pretendard-Light.woff2 b/frontend/packages/theme/assets/fonts/Pretendard-Light.woff2 new file mode 100644 index 00000000..7f82fe84 Binary files /dev/null and b/frontend/packages/theme/assets/fonts/Pretendard-Light.woff2 differ diff --git a/frontend/packages/theme/assets/fonts/Pretendard-Medium.woff2 b/frontend/packages/theme/assets/fonts/Pretendard-Medium.woff2 new file mode 100644 index 00000000..f8c743d6 Binary files /dev/null and b/frontend/packages/theme/assets/fonts/Pretendard-Medium.woff2 differ diff --git a/frontend/packages/theme/assets/fonts/Pretendard-Regular.woff2 b/frontend/packages/theme/assets/fonts/Pretendard-Regular.woff2 new file mode 100644 index 00000000..a9f62319 Binary files /dev/null and b/frontend/packages/theme/assets/fonts/Pretendard-Regular.woff2 differ diff --git a/frontend/packages/theme/assets/fonts/Pretendard-SemiBold.woff2 b/frontend/packages/theme/assets/fonts/Pretendard-SemiBold.woff2 new file mode 100644 index 00000000..4c6a32de Binary files /dev/null and b/frontend/packages/theme/assets/fonts/Pretendard-SemiBold.woff2 differ diff --git a/frontend/packages/theme/package.json b/frontend/packages/theme/package.json new file mode 100644 index 00000000..8d18b284 --- /dev/null +++ b/frontend/packages/theme/package.json @@ -0,0 +1,33 @@ +{ + "name": "@endolphin/theme", + "version": "1.0.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + }, + "./css": { + "import": "./dist/src/setup.js", + "types": "./dist/src/setup.d.ts" + } + }, + "devDependencies": { + "@endolphin/tsup-config": "workspace:^" + }, + "peerDependencies": { + "@endolphin/core": "workspace:^", + "@vanilla-extract/css": "^1.17.0", + "@vanilla-extract/recipes": "^0.5.5" + }, + "scripts": { + "build": "tsc -b && node scripts/copy-fonts.js", + "create-theme": "node ./scripts/create-theme.cjs & pnpm eslint --fix" + } +} diff --git a/frontend/packages/theme/scripts/copy-fonts.js b/frontend/packages/theme/scripts/copy-fonts.js new file mode 100644 index 00000000..0c62ff46 --- /dev/null +++ b/frontend/packages/theme/scripts/copy-fonts.js @@ -0,0 +1,21 @@ +import { copyFile, mkdir, readdir } from 'fs/promises'; +import { dirname, join } from 'path'; +import process from 'process'; +import { fileURLToPath } from 'url'; + +const _dirname = dirname(fileURLToPath(import.meta.url)); + +const main = async () => { + const src = join(_dirname, '../assets/fonts'); + const dest = join(_dirname, '../dist/assets/fonts'); + await mkdir(dest, { recursive: true }); + for (const file of await readdir(src)) { + await copyFile(join(src, file), join(dest, file)); + } +}; + +main().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); \ No newline at end of file diff --git a/frontend/src/scripts/create-theme.cjs b/frontend/packages/theme/scripts/create-theme.cjs similarity index 100% rename from frontend/src/scripts/create-theme.cjs rename to frontend/packages/theme/scripts/create-theme.cjs diff --git a/frontend/src/theme/animation.css.ts b/frontend/packages/theme/src/animation.css.ts similarity index 98% rename from frontend/src/theme/animation.css.ts rename to frontend/packages/theme/src/animation.css.ts index a393696d..7026d999 100644 --- a/frontend/src/theme/animation.css.ts +++ b/frontend/packages/theme/src/animation.css.ts @@ -1,7 +1,7 @@ import type { CSSProperties } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css'; -import { vars } from './index.css'; +import { vars } from './theme.css'; // TODO: 토큰 매직넘버 제거 diff --git a/frontend/src/theme/color.ts b/frontend/packages/theme/src/color.ts similarity index 100% rename from frontend/src/theme/color.ts rename to frontend/packages/theme/src/color.ts diff --git a/frontend/src/theme/font.ts b/frontend/packages/theme/src/font.ts similarity index 100% rename from frontend/src/theme/font.ts rename to frontend/packages/theme/src/font.ts diff --git a/frontend/src/theme/gradient.ts b/frontend/packages/theme/src/gradient.ts similarity index 100% rename from frontend/src/theme/gradient.ts rename to frontend/packages/theme/src/gradient.ts diff --git a/frontend/packages/theme/src/index.ts b/frontend/packages/theme/src/index.ts new file mode 100644 index 00000000..ad66352a --- /dev/null +++ b/frontend/packages/theme/src/index.ts @@ -0,0 +1,7 @@ +import './theme.css'; +import './reset.css'; + +export * as animation from './animation.css'; +export { font } from './font'; +export { vars } from './theme.css'; +export * as typo from './typo'; \ No newline at end of file diff --git a/frontend/src/theme/layers.css.ts b/frontend/packages/theme/src/layers.css.ts similarity index 100% rename from frontend/src/theme/layers.css.ts rename to frontend/packages/theme/src/layers.css.ts diff --git a/frontend/src/theme/radius.ts b/frontend/packages/theme/src/radius.ts similarity index 100% rename from frontend/src/theme/radius.ts rename to frontend/packages/theme/src/radius.ts diff --git a/frontend/src/theme/reset.css.ts b/frontend/packages/theme/src/reset.css.ts similarity index 100% rename from frontend/src/theme/reset.css.ts rename to frontend/packages/theme/src/reset.css.ts diff --git a/frontend/packages/theme/src/setup.ts b/frontend/packages/theme/src/setup.ts new file mode 100644 index 00000000..c0305b95 --- /dev/null +++ b/frontend/packages/theme/src/setup.ts @@ -0,0 +1,2 @@ +import './theme.css'; +import './reset.css'; \ No newline at end of file diff --git a/frontend/src/theme/spacing.ts b/frontend/packages/theme/src/spacing.ts similarity index 100% rename from frontend/src/theme/spacing.ts rename to frontend/packages/theme/src/spacing.ts diff --git a/frontend/src/theme/index.css.ts b/frontend/packages/theme/src/theme.css.ts similarity index 73% rename from frontend/src/theme/index.css.ts rename to frontend/packages/theme/src/theme.css.ts index bc9cef84..26db46bc 100644 --- a/frontend/src/theme/index.css.ts +++ b/frontend/packages/theme/src/theme.css.ts @@ -1,4 +1,3 @@ - import { createGlobalTheme, globalFontFace } from '@vanilla-extract/css'; import { color } from './color'; @@ -16,19 +15,19 @@ export const vars = createGlobalTheme(':root', { globalFontFace(fontFamilies.pretendard, [ { - src: 'url(/fonts/Pretendard-SemiBold.woff2)', + src: 'url(../assets/fonts/Pretendard-SemiBold.woff2)', fontWeight: fontWeights['pretendard-0'], }, { - src: 'url(/fonts/Pretendard-Medium.woff2)', + src: 'url(../assets/fonts/Pretendard-Medium.woff2)', fontWeight: fontWeights['pretendard-1'], }, { - src: 'url(/fonts/Pretendard-Regular.woff2)', + src: 'url(../assets/fonts/Pretendard-Regular.woff2)', fontWeight: fontWeights['pretendard-2'], }, { - src: 'url(/fonts/Pretendard-Light.woff2)', + src: 'url(../assets/fonts/Pretendard-Light.woff2)', fontWeight: fontWeights['pretendard-3'], }, ]); \ No newline at end of file diff --git a/frontend/src/theme/tokens/tokens.json b/frontend/packages/theme/src/tokens/tokens.json similarity index 100% rename from frontend/src/theme/tokens/tokens.json rename to frontend/packages/theme/src/tokens/tokens.json diff --git a/frontend/src/theme/typo.ts b/frontend/packages/theme/src/typo.ts similarity index 100% rename from frontend/src/theme/typo.ts rename to frontend/packages/theme/src/typo.ts diff --git a/frontend/packages/theme/tsconfig.json b/frontend/packages/theme/tsconfig.json new file mode 100644 index 00000000..7b930896 --- /dev/null +++ b/frontend/packages/theme/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "composite": true, + }, + "include": ["src"], +} \ No newline at end of file diff --git a/frontend/packages/theme/tsup.config.ts b/frontend/packages/theme/tsup.config.ts new file mode 100644 index 00000000..de8ea3f4 --- /dev/null +++ b/frontend/packages/theme/tsup.config.ts @@ -0,0 +1,6 @@ +import { reactConfig } from '@endolphin/tsup-config'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + ...reactConfig, +}); diff --git a/frontend/packages/ui/.gitignore b/frontend/packages/ui/.gitignore new file mode 100644 index 00000000..655f94ce --- /dev/null +++ b/frontend/packages/ui/.gitignore @@ -0,0 +1 @@ +tsup.config.bundled_*.mjs \ No newline at end of file diff --git a/frontend/packages/ui/.storybook/main.ts b/frontend/packages/ui/.storybook/main.ts new file mode 100644 index 00000000..b66e7ae4 --- /dev/null +++ b/frontend/packages/ui/.storybook/main.ts @@ -0,0 +1,42 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import { vanillaExtractPlugin as veEsbuildPlugin } from '@vanilla-extract/esbuild-plugin'; +import { vanillaExtractPlugin as veVitePlugin } from '@vanilla-extract/vite-plugin'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + viteFinal: async (config) => { + const { mergeConfig } = await import('vite'); + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + + return mergeConfig( + config, + { + plugins: [veVitePlugin()], + optimizeDeps: { + esbuildOptions: { + plugins: [veEsbuildPlugin({ runtime: true })], + }, + }, + resolve: { + alias: { + '@': path.resolve(dirname, '../src'), + '@hooks': path.resolve(dirname, '../src/hooks'), + '@components': path.resolve(dirname, '../src/components'), + }, + }, + }); + }, +}; +export default config; diff --git a/frontend/packages/ui/.storybook/preview.ts b/frontend/packages/ui/.storybook/preview.ts new file mode 100644 index 00000000..3792e452 --- /dev/null +++ b/frontend/packages/ui/.storybook/preview.ts @@ -0,0 +1,16 @@ +import '@endolphin/theme/css'; + +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/packages/ui/package.json b/frontend/packages/ui/package.json new file mode 100644 index 00000000..3822a8a6 --- /dev/null +++ b/frontend/packages/ui/package.json @@ -0,0 +1,37 @@ +{ + "name": "@endolphin/ui", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "start": "tsup --watch", + "test": "vitest", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "create-icon": "node ./scripts/create-icon.cjs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@endolphin/core": "workspace:^", + "@endolphin/theme": "workspace:^" + }, + "devDependencies": { + "@endolphin/tsup-config": "workspace:^", + "@endolphin/vitest-config": "workspace:^" + }, + "peerDependencies": { + "react": ">=19.0.0", + "react-dom": ">=19.0.0", + "@vanilla-extract/css": "^1.17.0", + "@vanilla-extract/dynamic": "^2.1.2", + "@vanilla-extract/recipes": "^0.5.5" + } +} diff --git a/frontend/src/scripts/create-icon.cjs b/frontend/packages/ui/scripts/create-icon.cjs similarity index 87% rename from frontend/src/scripts/create-icon.cjs rename to frontend/packages/ui/scripts/create-icon.cjs index ca553e72..02d69994 100644 --- a/frontend/src/scripts/create-icon.cjs +++ b/frontend/packages/ui/scripts/create-icon.cjs @@ -1,8 +1,9 @@ const { existsSync, promises: fs } = require("fs"); const path = require("path"); +const ICON_DIR_RELATIVE_PATH = "../src/components/Icon"; -const SVG_DIR = path.resolve(__dirname, "../components/Icon/svg"); -const COMPONENT_DIR = path.resolve(__dirname,"../components/Icon/component"); +const SVG_DIR = path.resolve(__dirname, `${ICON_DIR_RELATIVE_PATH}/svg`); +const COMPONENT_DIR = path.resolve(__dirname,`${ICON_DIR_RELATIVE_PATH}/component`); const generateSvgComponentMap = async () => { const svgFiles = (await fs.readdir(SVG_DIR)).reduce( @@ -50,10 +51,9 @@ const createComponentContent = ( const hasFill = fillAttributes.length; const propsString = `{ clickable = false, className, width = 24, height = 24${hasStroke || hasFill ? ` ${hasStroke ? ', stroke = "white"' : ""}${hasFill ? ', fill = "white"' : ""}` : ""}, ...rest }`; const modifiedSvgContent = svgContent - .replace(/style="mask-type:luminance"/g, "MASK_TYPE_PLACEHOLDER") + .replace(/style="mask-type:([^"]+)"/g, (_match, value) => `style={{ maskType: "${value}" }}`) .replace(/data:image/g, "DATA_IMAGE_PLACEHOLDER") - .replace(/[-:](\w)/g, (_, letter) => letter.toUpperCase()) - .replace(/MASK_TYPE_PLACEHOLDER/g, "mask-type='luminance'") + .replace(/[:\-]([A-Za-z])/g, (_, letter) => letter.toUpperCase()) .replace(/DATA_IMAGE_PLACEHOLDER/g, "data:image") .replace(/<svg([^>]*)width="(\d+)"/g, `<svg$1width={width}`) .replace(/<svg([^>]*)height="(\d+)"/g, `<svg$1height={height || width}`) @@ -109,11 +109,11 @@ const generateComponentFiles = async (svgComponentMap) => { }; const generateExportFile = async (components) => { - const EXPORT_FILE_PATH = path.resolve(__dirname, '../components/Icon/index.ts'); + const EXPORT_FILE_PATH = path.resolve(__dirname, `${ICON_DIR_RELATIVE_PATH}/index.ts`); const exportFileContent = components .map( (component) => - `export * from "./component/${component}.tsx";` + `export * from "./component/${component}";` ) .join("\n"); diff --git a/frontend/src/components/Avatar/Avatar.stories.tsx b/frontend/packages/ui/src/components/Avatar/Avatar.stories.tsx similarity index 100% rename from frontend/src/components/Avatar/Avatar.stories.tsx rename to frontend/packages/ui/src/components/Avatar/Avatar.stories.tsx diff --git a/frontend/src/components/Avatar/AvatarCount.tsx b/frontend/packages/ui/src/components/Avatar/AvatarCount.tsx similarity index 93% rename from frontend/src/components/Avatar/AvatarCount.tsx rename to frontend/packages/ui/src/components/Avatar/AvatarCount.tsx index 10a20487..2d071824 100644 --- a/frontend/src/components/Avatar/AvatarCount.tsx +++ b/frontend/packages/ui/src/components/Avatar/AvatarCount.tsx @@ -1,4 +1,4 @@ -import { vars } from '@/theme/index.css'; +import { vars } from '@endolphin/theme'; import { Flex } from '../Flex'; import type { Typo } from '../Text'; diff --git a/frontend/src/components/Avatar/AvatarItem.tsx b/frontend/packages/ui/src/components/Avatar/AvatarItem.tsx similarity index 100% rename from frontend/src/components/Avatar/AvatarItem.tsx rename to frontend/packages/ui/src/components/Avatar/AvatarItem.tsx diff --git a/frontend/src/components/Avatar/index.css.ts b/frontend/packages/ui/src/components/Avatar/index.css.ts similarity index 96% rename from frontend/src/components/Avatar/index.css.ts rename to frontend/packages/ui/src/components/Avatar/index.css.ts index ca8e7c78..10c87c63 100644 --- a/frontend/src/components/Avatar/index.css.ts +++ b/frontend/packages/ui/src/components/Avatar/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const avatarContainerStyle = style({ display: 'flex', alignItems: 'center', diff --git a/frontend/src/components/Avatar/index.tsx b/frontend/packages/ui/src/components/Avatar/index.tsx similarity index 77% rename from frontend/src/components/Avatar/index.tsx rename to frontend/packages/ui/src/components/Avatar/index.tsx index 417de5f4..8e5b18d5 100644 --- a/frontend/src/components/Avatar/index.tsx +++ b/frontend/packages/ui/src/components/Avatar/index.tsx @@ -1,4 +1,4 @@ -import clsx from '@/utils/clsx'; +import { clsx } from '@endolphin/core/utils'; import AvatarCount from './AvatarCount'; import AvatarItem from './AvatarItem'; @@ -9,7 +9,7 @@ export type Size = 'sm' | 'lg'; interface AvatarProps { size: Size; imageUrls: (string | null)[]; - onClick?: () => void; + onClick?: () => void; className?: string; } @@ -28,14 +28,9 @@ const Avatar = ({ size, imageUrls, onClick, className }: AvatarProps) => { src={url} /> ))} - {ENTIRE_LENGTH > MAX_IMAGE_COUNT && ( - <AvatarCount - count={ENTIRE_LENGTH} - size={size} - /> - )} + {ENTIRE_LENGTH > MAX_IMAGE_COUNT && <AvatarCount count={ENTIRE_LENGTH} size={size} />} </div> ); }; -export default Avatar; \ No newline at end of file +export default Avatar; diff --git a/frontend/src/components/Badge/Badge.stories.ts b/frontend/packages/ui/src/components/Badge/Badge.stories.ts similarity index 100% rename from frontend/src/components/Badge/Badge.stories.ts rename to frontend/packages/ui/src/components/Badge/Badge.stories.ts diff --git a/frontend/src/components/Badge/index.css.ts b/frontend/packages/ui/src/components/Badge/index.css.ts similarity index 88% rename from frontend/src/components/Badge/index.css.ts rename to frontend/packages/ui/src/components/Badge/index.css.ts index 86476e40..d466fd6d 100644 --- a/frontend/src/components/Badge/index.css.ts +++ b/frontend/packages/ui/src/components/Badge/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const badgeStyle = style({ border: `1px solid ${vars.color.Ref.Netural[200]}`, borderRadius: vars.radius['Max'], diff --git a/frontend/src/components/Badge/index.tsx b/frontend/packages/ui/src/components/Badge/index.tsx similarity index 96% rename from frontend/src/components/Badge/index.tsx rename to frontend/packages/ui/src/components/Badge/index.tsx index 8a04f48d..355dd29b 100644 --- a/frontend/src/components/Badge/index.tsx +++ b/frontend/packages/ui/src/components/Badge/index.tsx @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import type { PropsWithChildren } from 'react'; -import { vars } from '@/theme/index.css'; - import { CalendarCheck, Clock, PinLocation, UserTwo } from '../Icon'; import { Text } from '../Text'; import { badgeStyle } from './index.css'; diff --git a/frontend/src/components/Button/Button.stories.tsx b/frontend/packages/ui/src/components/Button/Button.stories.tsx similarity index 100% rename from frontend/src/components/Button/Button.stories.tsx rename to frontend/packages/ui/src/components/Button/Button.stories.tsx diff --git a/frontend/src/components/Button/ButtonIcon.tsx b/frontend/packages/ui/src/components/Button/ButtonIcon.tsx similarity index 100% rename from frontend/src/components/Button/ButtonIcon.tsx rename to frontend/packages/ui/src/components/Button/ButtonIcon.tsx diff --git a/frontend/src/components/Button/ButtonText.tsx b/frontend/packages/ui/src/components/Button/ButtonText.tsx similarity index 100% rename from frontend/src/components/Button/ButtonText.tsx rename to frontend/packages/ui/src/components/Button/ButtonText.tsx diff --git a/frontend/src/components/Button/buttonIcon.css.ts b/frontend/packages/ui/src/components/Button/buttonIcon.css.ts similarity index 100% rename from frontend/src/components/Button/buttonIcon.css.ts rename to frontend/packages/ui/src/components/Button/buttonIcon.css.ts diff --git a/frontend/src/components/Button/index.css.ts b/frontend/packages/ui/src/components/Button/index.css.ts similarity index 98% rename from frontend/src/components/Button/index.css.ts rename to frontend/packages/ui/src/components/Button/index.css.ts index cc9e1d4e..9fbd3f57 100644 --- a/frontend/src/components/Button/index.css.ts +++ b/frontend/packages/ui/src/components/Button/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = recipe({ base: { display: 'flex', diff --git a/frontend/src/components/Button/index.tsx b/frontend/packages/ui/src/components/Button/index.tsx similarity index 82% rename from frontend/src/components/Button/index.tsx rename to frontend/packages/ui/src/components/Button/index.tsx index 5de56051..649bef7a 100644 --- a/frontend/src/components/Button/index.tsx +++ b/frontend/packages/ui/src/components/Button/index.tsx @@ -1,12 +1,6 @@ -import type { - ComponentPropsWithoutRef, - ElementType, - JSX, - MouseEventHandler, -} from 'react'; - -import type { AsProp } from '@/types/polymorphism'; -import clsx from '@/utils/clsx'; +import type { AsProp } from '@endolphin/core/types'; +import { clsx } from '@endolphin/core/utils'; +import type { ComponentPropsWithoutRef, ElementType, JSX, MouseEventHandler } from 'react'; import ButtonIcon from './ButtonIcon'; import ButtonText from './ButtonText'; @@ -42,7 +36,7 @@ const Button = <T extends ElementType = 'button'>({ const Component = as || 'button'; return ( - <Component + <Component className={clsx(containerStyle({ variant, style, radius, size, disabled }), className)} {...props} {...accessibilityProps(Component, disabled, onClick)} @@ -56,20 +50,18 @@ const Button = <T extends ElementType = 'button'>({ /** * 버튼인 경우에는 disable 속성 주입, 버튼이 아닌 경우엔 aria-disabled와 클릭 방지 처리 - * */ + * */ const accessibilityProps = ( - Component: ElementType, + Component: ElementType, disabled: boolean, onClick?: MouseEventHandler, ) => { const isIntrinsicButton = typeof Component === 'string' && Component === 'button'; - if (isIntrinsicButton) return { disabled, 'aria-disabled': disabled, onClick }; + if (isIntrinsicButton) return { disabled, 'aria-disabled': disabled, onClick }; if (!onClick) return {}; - const guardedOnClick: MouseEventHandler = disabled - ? (event) => event.preventDefault() - : onClick; - + const guardedOnClick: MouseEventHandler = disabled ? (event) => event.preventDefault() : onClick; + return { 'aria-disabled': disabled, onClick: guardedOnClick, diff --git a/frontend/src/components/Checkbox/Checkbox.stories.tsx b/frontend/packages/ui/src/components/Checkbox/Checkbox.stories.tsx similarity index 97% rename from frontend/src/components/Checkbox/Checkbox.stories.tsx rename to frontend/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index 598df69c..4edf71c1 100644 --- a/frontend/src/components/Checkbox/Checkbox.stories.tsx +++ b/frontend/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -1,8 +1,7 @@ +import { useGroup } from '@hooks/useGroup'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { useGroup } from '@/hooks/useGroup'; - import { Group } from '../Group'; import { Checkbox } from '.'; diff --git a/frontend/src/components/Checkbox/CheckboxInput.tsx b/frontend/packages/ui/src/components/Checkbox/CheckboxInput.tsx similarity index 100% rename from frontend/src/components/Checkbox/CheckboxInput.tsx rename to frontend/packages/ui/src/components/Checkbox/CheckboxInput.tsx diff --git a/frontend/src/components/Checkbox/CheckboxLabel.tsx b/frontend/packages/ui/src/components/Checkbox/CheckboxLabel.tsx similarity index 100% rename from frontend/src/components/Checkbox/CheckboxLabel.tsx rename to frontend/packages/ui/src/components/Checkbox/CheckboxLabel.tsx diff --git a/frontend/src/components/Checkbox/index.css.ts b/frontend/packages/ui/src/components/Checkbox/index.css.ts similarity index 97% rename from frontend/src/components/Checkbox/index.css.ts rename to frontend/packages/ui/src/components/Checkbox/index.css.ts index 3371a856..a4a0bd57 100644 --- a/frontend/src/components/Checkbox/index.css.ts +++ b/frontend/packages/ui/src/components/Checkbox/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = style({ width: 'fit-content', diff --git a/frontend/src/components/Checkbox/index.tsx b/frontend/packages/ui/src/components/Checkbox/index.tsx similarity index 97% rename from frontend/src/components/Checkbox/index.tsx rename to frontend/packages/ui/src/components/Checkbox/index.tsx index f55d7e82..a615f51b 100644 --- a/frontend/src/components/Checkbox/index.tsx +++ b/frontend/packages/ui/src/components/Checkbox/index.tsx @@ -1,7 +1,6 @@ +import { useCheckbox } from '@hooks/useCheckbox'; import { useId } from 'react'; -import { useCheckbox } from '@/hooks/useCheckbox'; - import { Check } from '../Icon'; import { CheckboxInput } from './CheckboxInput'; import { CheckboxLabel } from './CheckboxLabel'; diff --git a/frontend/src/components/Checkbox/type.ts b/frontend/packages/ui/src/components/Checkbox/type.ts similarity index 100% rename from frontend/src/components/Checkbox/type.ts rename to frontend/packages/ui/src/components/Checkbox/type.ts diff --git a/frontend/src/components/Chip/Chip.stories.ts b/frontend/packages/ui/src/components/Chip/Chip.stories.ts similarity index 93% rename from frontend/src/components/Chip/Chip.stories.ts rename to frontend/packages/ui/src/components/Chip/Chip.stories.ts index 76d9e8d9..3348487a 100644 --- a/frontend/src/components/Chip/Chip.stories.ts +++ b/frontend/packages/ui/src/components/Chip/Chip.stories.ts @@ -10,7 +10,7 @@ const meta: Meta = { options: ['blue', 'green', 'red', 'black'], control: { type: 'radio' }, }, - style: { + variant: { options: ['borderless', 'weak', 'filled'], control: { type: 'radio' }, }, @@ -36,7 +36,7 @@ export const Primary: Story = { args: { children: '라벨', color: 'blue', - style: 'weak', + variant: 'weak', radius: 'round', size: 'md', }, @@ -46,7 +46,7 @@ export const Borderness: Story = { args: { children: '라벨', color: 'blue', - style: 'borderless', + variant: 'borderless', radius: 'round', size: 'md', }, diff --git a/frontend/src/components/Chip/index.css.ts b/frontend/packages/ui/src/components/Chip/index.css.ts similarity index 92% rename from frontend/src/components/Chip/index.css.ts rename to frontend/packages/ui/src/components/Chip/index.css.ts index 114baa9e..3115129b 100644 --- a/frontend/src/components/Chip/index.css.ts +++ b/frontend/packages/ui/src/components/Chip/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const chipStyle = recipe({ base: { width: 'fit-content', @@ -33,7 +32,7 @@ export const chipStyle = recipe({ color: vars.color.Ref.Netural[600], }, }, - style: { + variant: { borderless: { backgroundColor: 'transparent', color: vars.color.Ref.Netural[600], @@ -56,7 +55,7 @@ export const chipStyle = recipe({ { variants: { color: 'blue', - style: 'weak', + variant: 'weak', }, style: { backgroundColor: vars.color.Ref.Primary[50], @@ -66,7 +65,7 @@ export const chipStyle = recipe({ { variants: { color: 'red', - style: 'weak', + variant: 'weak', }, style: { backgroundColor: vars.color.Ref.Red[50], @@ -76,7 +75,7 @@ export const chipStyle = recipe({ { variants: { color: 'green', - style: 'weak', + variant: 'weak', }, style: { backgroundColor: vars.color.Ref.Green[50], @@ -86,7 +85,7 @@ export const chipStyle = recipe({ { variants: { color: 'black', - style: 'weak', + variant: 'weak', }, style: { backgroundColor: vars.color.Ref.Netural[100], @@ -106,7 +105,7 @@ export const chipStyle = recipe({ defaultVariants: { color: 'blue', - style: 'filled', + variant: 'filled', radius: 'round', size: 'md', }, diff --git a/frontend/src/components/Chip/index.tsx b/frontend/packages/ui/src/components/Chip/index.tsx similarity index 61% rename from frontend/src/components/Chip/index.tsx rename to frontend/packages/ui/src/components/Chip/index.tsx index 09c2bbe7..efc8087f 100644 --- a/frontend/src/components/Chip/index.tsx +++ b/frontend/packages/ui/src/components/Chip/index.tsx @@ -1,12 +1,13 @@ -import type { PropsWithChildren } from 'react'; +import type { DefaultProps } from '@endolphin/core/types'; +import { clsx } from '@endolphin/core/utils'; import type { Typo } from '../Text'; import { Text } from '../Text'; import { chipStyle } from './index.css'; -interface ChipProps extends PropsWithChildren { +interface ChipProps extends DefaultProps { color: 'blue' | 'green' | 'red' | 'black' | 'coolGray'; - style?: 'borderless' | 'weak' | 'filled'; + variant?: 'borderless' | 'weak' | 'filled'; radius?: 'round' | 'max'; size?: 'sm' | 'md' | 'lg'; } @@ -15,17 +16,19 @@ interface ChipProps extends PropsWithChildren { * @description Chip 컴포넌트. * * @param color - Chip의 색상. - * @param style - Chip의 스타일. + * @param variant - Chip의 스타일. * @param radius - Chip의 모서리 둥글기. * @param size - Chip의 크기. * @param children - Chip의 내용. */ -export const Chip = ({ - color, - style = 'weak', +export const Chip = ({ + color, + variant = 'weak', radius = 'round', size = 'sm', - children, + className, + children, + ...rest }: ChipProps) => { const fontMap: Record<typeof size, Typo> = { sm: 'b3M', @@ -34,8 +37,8 @@ export const Chip = ({ }; return ( - <div className={chipStyle({ color, style, radius, size })}> + <div className={clsx(chipStyle({ color, variant, radius, size }), className)} {...rest}> <Text typo={fontMap[size]}>{children}</Text> </div> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Divider/Divider.stories.tsx b/frontend/packages/ui/src/components/Divider/Divider.stories.tsx similarity index 90% rename from frontend/src/components/Divider/Divider.stories.tsx rename to frontend/packages/ui/src/components/Divider/Divider.stories.tsx index d7fea5d9..480cc382 100644 --- a/frontend/src/components/Divider/Divider.stories.tsx +++ b/frontend/packages/ui/src/components/Divider/Divider.stories.tsx @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import type { Meta, StoryObj } from '@storybook/react'; -import { vars } from '@/theme/index.css'; - import { Divider } from '.'; const meta: Meta = { diff --git a/frontend/src/components/Divider/index.css.ts b/frontend/packages/ui/src/components/Divider/index.css.ts similarity index 100% rename from frontend/src/components/Divider/index.css.ts rename to frontend/packages/ui/src/components/Divider/index.css.ts diff --git a/frontend/src/components/Divider/index.tsx b/frontend/packages/ui/src/components/Divider/index.tsx similarity index 72% rename from frontend/src/components/Divider/index.tsx rename to frontend/packages/ui/src/components/Divider/index.tsx index 83355d3f..b7396ec8 100644 --- a/frontend/src/components/Divider/index.tsx +++ b/frontend/packages/ui/src/components/Divider/index.tsx @@ -1,8 +1,7 @@ +import { clsx } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; import { assignInlineVars } from '@vanilla-extract/dynamic'; -import { vars } from '@/theme/index.css'; -import clsx from '@/utils/clsx'; - import { dividerStyle, dividerVars } from './index.css'; type Width = number | string | 'full'; @@ -14,11 +13,11 @@ interface DividerProps { className?: string; } -export const Divider = ({ - width = 'full', - height = 1, - color = vars.color.Ref.Netural[200], - className, +export const Divider = ({ + width = 'full', + height = 1, + color = vars.color.Ref.Netural[200], + className, }: DividerProps) => { const formatWidth = (width: Width) => { if (width === 'full') return '100%'; @@ -29,8 +28,8 @@ export const Divider = ({ return ( <div className={clsx(className, dividerStyle)} - style={assignInlineVars(dividerVars, { - divider: { + style={assignInlineVars(dividerVars, { + divider: { width: formatWidth(width), height: typeof height === 'number' ? `${height}px` : height, color, @@ -38,4 +37,4 @@ export const Divider = ({ })} /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Dropdown/Dropdown.stories.tsx b/frontend/packages/ui/src/components/Dropdown/Dropdown.stories.tsx similarity index 100% rename from frontend/src/components/Dropdown/Dropdown.stories.tsx rename to frontend/packages/ui/src/components/Dropdown/Dropdown.stories.tsx diff --git a/frontend/src/components/Dropdown/DropdownContents.tsx b/frontend/packages/ui/src/components/Dropdown/DropdownContents.tsx similarity index 74% rename from frontend/src/components/Dropdown/DropdownContents.tsx rename to frontend/packages/ui/src/components/Dropdown/DropdownContents.tsx index 3c75bf3b..e12c3cd3 100644 --- a/frontend/src/components/Dropdown/DropdownContents.tsx +++ b/frontend/packages/ui/src/components/Dropdown/DropdownContents.tsx @@ -1,17 +1,16 @@ +import { clsx } from '@endolphin/core/utils'; import type { CSSProperties, PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - import { dropdownContentStyle } from './index.css'; -interface DropdownContentsProps extends PropsWithChildren { +export interface DropdownContentsProps extends PropsWithChildren { width?: number | string; height?: number | string; style?: CSSProperties; className?: string; } -export const DropdownContents = ({ +export const DropdownContents = ({ children, className, style, @@ -21,4 +20,4 @@ export const DropdownContents = ({ <ul className={clsx(dropdownContentStyle, className)} style={{ width, height, ...style }}> {children} </ul> -); \ No newline at end of file +); diff --git a/frontend/src/components/Dropdown/DropdownContext.ts b/frontend/packages/ui/src/components/Dropdown/DropdownContext.ts similarity index 68% rename from frontend/src/components/Dropdown/DropdownContext.ts rename to frontend/packages/ui/src/components/Dropdown/DropdownContext.ts index 97c4c0e1..75b979b4 100644 --- a/frontend/src/components/Dropdown/DropdownContext.ts +++ b/frontend/packages/ui/src/components/Dropdown/DropdownContext.ts @@ -1,4 +1,4 @@ -import { createContext } from 'react'; +import { createStateContext } from '@endolphin/core/utils'; interface DropdownContextProps { controlId: string; @@ -15,4 +15,7 @@ interface DropdownContextProps { * @param onChange - Dropdown 컴포넌트의 값 변경 함수. * @param setIsOpen - Dropdown 컴포넌트의 오픈 상태 변경 함수. */ -export const DropdownContext = createContext<DropdownContextProps | null>(null); \ No newline at end of file +export const { + StateProvider: DropdownProvider, + useContextState: useDropdownContext, +} = createStateContext<DropdownContextProps, DropdownContextProps, object>(); \ No newline at end of file diff --git a/frontend/src/components/Dropdown/DropdownItem.tsx b/frontend/packages/ui/src/components/Dropdown/DropdownItem.tsx similarity index 68% rename from frontend/src/components/Dropdown/DropdownItem.tsx rename to frontend/packages/ui/src/components/Dropdown/DropdownItem.tsx index ac705de2..ff9fd290 100644 --- a/frontend/src/components/Dropdown/DropdownItem.tsx +++ b/frontend/packages/ui/src/components/Dropdown/DropdownItem.tsx @@ -1,14 +1,12 @@ +import { clsx } from '@endolphin/core/utils'; import type { CSSProperties, PropsWithChildren } from 'react'; import { useId } from 'react'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import clsx from '@/utils/clsx'; - import { Text } from '../Text'; -import { DropdownContext } from './DropdownContext'; +import { useDropdownContext } from './DropdownContext'; import { dropdownStyle } from './dropdownItem.css'; -interface DropdownItemProps extends PropsWithChildren { +export interface DropdownItemProps extends PropsWithChildren { value: string; style?: CSSProperties; className?: string; @@ -16,7 +14,7 @@ interface DropdownItemProps extends PropsWithChildren { } export const DropdownItem = ({ value, style, onClick, className, children }: DropdownItemProps) => { - const { controlId, selectedValue, onChange, setIsOpen } = useSafeContext(DropdownContext); + const { controlId, selectedValue, onChange, setIsOpen } = useDropdownContext(); const defaultId = `${controlId}-item-${useId()}`; const handleClick = () => { @@ -26,15 +24,15 @@ export const DropdownItem = ({ value, style, onClick, className, children }: Dro }; const isSelected = selectedValue === value; - + return ( <li className={clsx(className, dropdownStyle({ state: isSelected ? 'selected' : 'rest' }))} - id={defaultId} - onClick={handleClick} + id={defaultId} + onClick={handleClick} style={style} > <Text typo='b3R'>{children}</Text> </li> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Dropdown/dropdownItem.css.ts b/frontend/packages/ui/src/components/Dropdown/dropdownItem.css.ts similarity index 92% rename from frontend/src/components/Dropdown/dropdownItem.css.ts rename to frontend/packages/ui/src/components/Dropdown/dropdownItem.css.ts index 6091b74f..093c897b 100644 --- a/frontend/src/components/Dropdown/dropdownItem.css.ts +++ b/frontend/packages/ui/src/components/Dropdown/dropdownItem.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const dropdownStyle = recipe({ base: { width: '100%', diff --git a/frontend/src/components/Dropdown/index.css.ts b/frontend/packages/ui/src/components/Dropdown/index.css.ts similarity index 95% rename from frontend/src/components/Dropdown/index.css.ts rename to frontend/packages/ui/src/components/Dropdown/index.css.ts index 7fbd98c0..f68d0d83 100644 --- a/frontend/src/components/Dropdown/index.css.ts +++ b/frontend/packages/ui/src/components/Dropdown/index.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const dropdownContainerStyle = style({ height: '100%', position: 'relative', diff --git a/frontend/src/components/Dropdown/index.tsx b/frontend/packages/ui/src/components/Dropdown/index.tsx similarity index 86% rename from frontend/src/components/Dropdown/index.tsx rename to frontend/packages/ui/src/components/Dropdown/index.tsx index d203fce2..a8d19d73 100644 --- a/frontend/src/components/Dropdown/index.tsx +++ b/frontend/packages/ui/src/components/Dropdown/index.tsx @@ -1,10 +1,9 @@ +import { useClickOutside } from '@endolphin/core/hooks'; import type { CSSProperties, PropsWithChildren, ReactNode } from 'react'; import { useId, useState } from 'react'; -import { useClickOutside } from '@/hooks/useClickOutside'; - import { DropdownContents } from './DropdownContents'; -import { DropdownContext } from './DropdownContext'; +import { DropdownProvider } from './DropdownContext'; import { DropdownItem } from './DropdownItem'; import { dropdownContainerStyle, dropdownTriggerStyle } from './index.css'; @@ -28,8 +27,8 @@ export const Dropdown = ({ const [isOpen, setIsOpen] = useState(false); const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false)); return ( - <DropdownContext.Provider - value={{ + <DropdownProvider + initialValue={{ controlId: defaultId, selectedValue, onChange, @@ -47,7 +46,7 @@ export const Dropdown = ({ </div> {isOpen && children} </div> - </DropdownContext.Provider> + </DropdownProvider> ); }; diff --git a/frontend/src/components/Flex/Flex.stories.tsx b/frontend/packages/ui/src/components/Flex/Flex.stories.tsx similarity index 100% rename from frontend/src/components/Flex/Flex.stories.tsx rename to frontend/packages/ui/src/components/Flex/Flex.stories.tsx diff --git a/frontend/src/components/Flex/flex.css.ts b/frontend/packages/ui/src/components/Flex/flex.css.ts similarity index 100% rename from frontend/src/components/Flex/flex.css.ts rename to frontend/packages/ui/src/components/Flex/flex.css.ts diff --git a/frontend/src/components/Flex/index.tsx b/frontend/packages/ui/src/components/Flex/index.tsx similarity index 77% rename from frontend/src/components/Flex/index.tsx rename to frontend/packages/ui/src/components/Flex/index.tsx index 53a61e56..018626e2 100644 --- a/frontend/src/components/Flex/index.tsx +++ b/frontend/packages/ui/src/components/Flex/index.tsx @@ -1,9 +1,8 @@ +import type { AsProp } from '@endolphin/core/types'; +import { clsx } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; import { assignInlineVars } from '@vanilla-extract/dynamic'; -import type { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react'; - -import { vars } from '@/theme/index.css'; -import type { AsProp } from '@/types/polymorphism'; -import clsx from '@/utils/clsx'; +import type { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react'; import { flexStyle, flexVars } from './flex.css'; @@ -17,22 +16,21 @@ interface FlexProps extends PropsWithChildren { className?: string; } -export const Flex = <T extends ElementType = 'div'>({ +export const Flex = <T extends ElementType = 'div'>({ as, className, - children, + children, width = 'auto', height = 'auto', - direction = 'row', + direction = 'row', justify = 'center', align = 'flex-start', gap, ...props }: AsProp<T> & ComponentPropsWithoutRef<T> & FlexProps) => { - const Component = as || 'div'; - const formattedWidth = (()=>{ + const formattedWidth = (() => { if (width === 'full') return '100%'; if (typeof width === 'number') return `${width}px`; return width; @@ -41,8 +39,8 @@ export const Flex = <T extends ElementType = 'div'>({ return ( <Component className={clsx(className, flexStyle)} - style={assignInlineVars(flexVars, { - flex: { + style={assignInlineVars(flexVars, { + flex: { width: formattedWidth, height: typeof height === 'number' ? `${height}px` : height, direction, @@ -56,4 +54,4 @@ export const Flex = <T extends ElementType = 'div'>({ {children} </Component> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Group/GroupContext.ts b/frontend/packages/ui/src/components/Group/GroupContext.ts similarity index 73% rename from frontend/src/components/Group/GroupContext.ts rename to frontend/packages/ui/src/components/Group/GroupContext.ts index 539c694b..82bd8f56 100644 --- a/frontend/src/components/Group/GroupContext.ts +++ b/frontend/packages/ui/src/components/Group/GroupContext.ts @@ -1,15 +1,4 @@ -import { createContext } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; - -interface GroupContextProps { - controlId: string; - checkedList: Set<number>; - onToggleCheck: (id: number) => void; - onToggleAllCheck: () => void; - reset: () => void; - isAllChecked: boolean; -} +import { createStateContext } from '@endolphin/core/utils'; /** * @description Group 컴포넌트의 Context. @@ -21,6 +10,17 @@ interface GroupContextProps { * @param [reset] - Group 컴포넌트의 체크박스를 초기화하는 함수. * @param [isAllChecked] - Group 컴포넌트의 전체 체크박스가 체크되었는지 여부. */ -export const GroupContext = createContext<GroupContextProps | null>(null); +interface GroupContextProps { + controlId: string; + checkedList: Set<number>; + onToggleCheck: (id: number) => void; + onToggleAllCheck: () => void; + reset: () => void; + isAllChecked: boolean; +} -export const useGroupContext = () => useSafeContext(GroupContext); \ No newline at end of file +export const { + StateProvider: GroupProvider, + useContextState: useGroupContext, + useUnsafeContextState: useUnsafeGroupContext, +} = createStateContext<GroupContextProps, GroupContextProps, object>(); \ No newline at end of file diff --git a/frontend/src/components/Group/index.tsx b/frontend/packages/ui/src/components/Group/index.tsx similarity index 80% rename from frontend/src/components/Group/index.tsx rename to frontend/packages/ui/src/components/Group/index.tsx index a7120303..36ef2094 100644 --- a/frontend/src/components/Group/index.tsx +++ b/frontend/packages/ui/src/components/Group/index.tsx @@ -1,9 +1,8 @@ +import type { GroupStateReturn } from '@hooks/useGroup'; import type { PropsWithChildren } from 'react'; import { useId } from 'react'; -import type { GroupStateReturn } from '@/hooks/useGroup'; - -import { GroupContext } from './GroupContext'; +import { GroupProvider } from './GroupContext'; export interface GroupProps extends PropsWithChildren { groupInfos: GroupStateReturn; @@ -23,8 +22,8 @@ export const Group = ({ const { checkedList, isAllChecked, handleToggleCheck, handleToggleAllCheck, reset } = groupInfos; return ( - <GroupContext.Provider - value={{ + <GroupProvider + initialValue={{ controlId: controlId || defaultId, checkedList, onToggleCheck: handleToggleCheck, @@ -34,6 +33,6 @@ export const Group = ({ }} > {children} - </GroupContext.Provider> + </GroupProvider> ); }; \ No newline at end of file diff --git a/frontend/src/components/Icon/Icon.d.ts b/frontend/packages/ui/src/components/Icon/Icon.d.ts similarity index 100% rename from frontend/src/components/Icon/Icon.d.ts rename to frontend/packages/ui/src/components/Icon/Icon.d.ts diff --git a/frontend/src/components/Icon/component/ArrowLeft.tsx b/frontend/packages/ui/src/components/Icon/component/ArrowLeft.tsx similarity index 92% rename from frontend/src/components/Icon/component/ArrowLeft.tsx rename to frontend/packages/ui/src/components/Icon/component/ArrowLeft.tsx index 25b45963..2811495b 100644 --- a/frontend/src/components/Icon/component/ArrowLeft.tsx +++ b/frontend/packages/ui/src/components/Icon/component/ArrowLeft.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const ArrowLeft = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="arrow-left icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_173" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_173" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_173)"> diff --git a/frontend/src/components/Icon/component/Calendar.tsx b/frontend/packages/ui/src/components/Icon/component/Calendar.tsx similarity index 100% rename from frontend/src/components/Icon/component/Calendar.tsx rename to frontend/packages/ui/src/components/Icon/component/Calendar.tsx diff --git a/frontend/src/components/Icon/component/CalendarCheck.tsx b/frontend/packages/ui/src/components/Icon/component/CalendarCheck.tsx similarity index 95% rename from frontend/src/components/Icon/component/CalendarCheck.tsx rename to frontend/packages/ui/src/components/Icon/component/CalendarCheck.tsx index 6054eb06..370f9f1a 100644 --- a/frontend/src/components/Icon/component/CalendarCheck.tsx +++ b/frontend/packages/ui/src/components/Icon/component/CalendarCheck.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const CalendarCheck = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="calendar-check icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_699" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_699" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_699)"> diff --git a/frontend/packages/ui/src/components/Icon/component/CalendarMini.tsx b/frontend/packages/ui/src/components/Icon/component/CalendarMini.tsx new file mode 100644 index 00000000..503de07a --- /dev/null +++ b/frontend/packages/ui/src/components/Icon/component/CalendarMini.tsx @@ -0,0 +1,18 @@ + +import type { IconProps } from '../Icon.d.ts'; + +export const CalendarMini = ({ clickable = false, className, width = 24, height = 24 , stroke = "white", ...rest }: IconProps) => { + return ( + <svg width={width} height={height || width} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-label="calendar-mini icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> +<path d="M6.5 4V5.5M13.5 4V5.5M4 14.5V7C4 6.60218 4.15804 6.22064 4.43934 5.93934C4.72064 5.65804 5.10218 5.5 5.5 5.5H14.5C14.8978 5.5 15.2794 5.65804 15.5607 5.93934C15.842 6.22064 16 6.60218 16 7V14.5M4 14.5C4 14.8978 4.15804 15.2794 4.43934 15.5607C4.72064 15.842 5.10218 16 5.5 16H14.5C14.8978 16 15.2794 15.842 15.5607 15.5607C15.842 15.2794 16 14.8978 16 14.5M4 14.5V9.5C4 9.10218 4.15804 8.72064 4.43934 8.43934C4.72064 8.15804 5.10218 8 5.5 8H14.5C14.8978 8 15.2794 8.15804 15.5607 8.43934C15.842 8.72064 16 9.10218 16 9.5V14.5" stroke="#3182F6" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/> +<path d="M7.875 10.8677C7.875 11.0748 7.70711 11.2427 7.5 11.2427C7.29289 11.2427 7.125 11.0748 7.125 10.8677C7.125 10.6606 7.29289 10.4927 7.5 10.4927C7.70711 10.4927 7.875 10.6606 7.875 10.8677Z" stroke="#3182F6" strokeWidth="0.75"/> +<path d="M7.875 13.1943C7.875 13.4014 7.70711 13.5693 7.5 13.5693C7.29289 13.5693 7.125 13.4014 7.125 13.1943C7.125 12.9872 7.29289 12.8193 7.5 12.8193C7.70711 12.8193 7.875 12.9872 7.875 13.1943Z" stroke="#3182F6" strokeWidth="0.75"/> +<path d="M12.875 10.8677C12.875 11.0748 12.7071 11.2427 12.5 11.2427C12.2929 11.2427 12.125 11.0748 12.125 10.8677C12.125 10.6606 12.2929 10.4927 12.5 10.4927C12.7071 10.4927 12.875 10.6606 12.875 10.8677Z" stroke="#3182F6" strokeWidth="0.75"/> +<path d="M10.375 10.8677C10.375 11.0748 10.2071 11.2427 10 11.2427C9.79289 11.2427 9.625 11.0748 9.625 10.8677C9.625 10.6606 9.79289 10.4927 10 10.4927C10.2071 10.4927 10.375 10.6606 10.375 10.8677Z" stroke="#3182F6" strokeWidth="0.75"/> +<path d="M10.375 13.1943C10.375 13.4014 10.2071 13.5693 10 13.5693C9.79289 13.5693 9.625 13.4014 9.625 13.1943C9.625 12.9872 9.79289 12.8193 10 12.8193C10.2071 12.8193 10.375 12.9872 10.375 13.1943Z" stroke="#3182F6" strokeWidth="0.75"/> +</svg> + + ); +}; + +CalendarMini.displayName = 'CalendarMini'; diff --git a/frontend/src/components/Icon/component/Check.tsx b/frontend/packages/ui/src/components/Icon/component/Check.tsx similarity index 87% rename from frontend/src/components/Icon/component/Check.tsx rename to frontend/packages/ui/src/components/Icon/component/Check.tsx index 6208f9c6..ee843afd 100644 --- a/frontend/src/components/Icon/component/Check.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Check.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const Check = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="check icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_1002_7989" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_1002_7989" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_1002_7989)"> diff --git a/frontend/packages/ui/src/components/Icon/component/CheckGraphic.tsx b/frontend/packages/ui/src/components/Icon/component/CheckGraphic.tsx new file mode 100644 index 00000000..447cf06c --- /dev/null +++ b/frontend/packages/ui/src/components/Icon/component/CheckGraphic.tsx @@ -0,0 +1,65 @@ + +import type { IconProps } from '../Icon.d.ts'; + +export const CheckGraphic = ({ clickable = false, className, width = 24, height = 24 , stroke = "white", fill = "white", ...rest }: IconProps) => { + return ( + <svg width={width} height={height || width} viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg" aria-label="check-graphic icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> +<g clipPath="url(#clip0_894_193)"> +<mask id="mask0_894_193" style={{ maskType: "alpha" }} maskUnits="userSpaceOnUse" x="0" y="0" width="180" height="180"> +<rect width="180" height="180" fill="white"/> +</mask> +<g mask="url(#mask0_894_193)"> +<rect width="180" height="180" fill="#F3F6FC"/> +<g filter="url(#filter0_f_894_193)"> +<circle cx="115.2" cy="88.2" r="27" fill="#90C2FF"/> +</g> +<g filter="url(#filter1_f_894_193)"> +<ellipse cx="46.35" cy="132.3" rx="14.85" ry="27" fill="#76E4B8"/> +</g> +<g filter="url(#filter2_d_894_193)"> +<rect x="77.8491" y="65.25" width="72" height="72" rx="16" transform="rotate(10 77.8491 65.25)" fill="white"/> +<rect x="78.2547" y="65.8292" width="71" height="71" rx="15.5" transform="rotate(10 78.2547 65.8292)" stroke="#E5E8EB"/> +</g> +<mask id="mask1_894_193" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="78" y="78" width="58" height="58"> +<rect x="87.3398" y="78.8045" width="48.6" height="48.6" transform="rotate(10 87.3398 78.8045)" fill="white"/> +</mask> +<g mask="url(#mask1_894_193)"> +<path d="M100.976 115.754L93.4197 104.963C92.5984 103.79 92.8446 102.394 94.0175 101.573C95.1904 100.751 96.5864 100.998 97.4077 102.17L103.485 110.85L118.968 100.009C120.141 99.1877 121.537 99.4339 122.358 100.607C123.18 101.78 122.933 103.176 121.76 103.997L104.401 116.152C103.662 116.844 103.029 116.938 102.43 116.833C101.832 116.727 101.269 116.422 100.976 115.754Z" fill="url(#paint0_linear_894_193)"/> +</g> +</g> +</g> +<defs> +<filter id="filter0_f_894_193" x="-11.8" y="-38.8" width="254" height="254" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> +<feFlood floodOpacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur_894_193"/> +</filter> +<filter id="filter1_f_894_193" x="-38.5" y="35.3" width="169.7" height="194" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> +<feFlood floodOpacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="35" result="effect1_foregroundBlur_894_193"/> +</filter> +<filter id="filter2_d_894_193" x="47.8791" y="47.7826" width="118.344" height="118.344" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> +<feFlood floodOpacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="10"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_894_193"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_894_193" result="shape"/> +</filter> +<linearGradient id="paint0_linear_894_193" x1="108.83" y1="97.4502" x2="105.322" y2="117.343" gradientUnits="userSpaceOnUse"> +<stop stopColor="#64A8FF"/> +<stop offset="1" stopColor="#3182F6"/> +</linearGradient> +<clipPath id="clip0_894_193"> +<rect width="180" height="180" rx="90" fill="white"/> +</clipPath> +</defs> +</svg> + + ); +}; + +CheckGraphic.displayName = 'CheckGraphic'; diff --git a/frontend/src/components/Icon/component/ChevronDown.tsx b/frontend/packages/ui/src/components/Icon/component/ChevronDown.tsx similarity index 100% rename from frontend/src/components/Icon/component/ChevronDown.tsx rename to frontend/packages/ui/src/components/Icon/component/ChevronDown.tsx diff --git a/frontend/src/components/Icon/component/ChevronLeft.tsx b/frontend/packages/ui/src/components/Icon/component/ChevronLeft.tsx similarity index 100% rename from frontend/src/components/Icon/component/ChevronLeft.tsx rename to frontend/packages/ui/src/components/Icon/component/ChevronLeft.tsx diff --git a/frontend/src/components/Icon/component/ChevronRight.tsx b/frontend/packages/ui/src/components/Icon/component/ChevronRight.tsx similarity index 100% rename from frontend/src/components/Icon/component/ChevronRight.tsx rename to frontend/packages/ui/src/components/Icon/component/ChevronRight.tsx diff --git a/frontend/src/components/Icon/component/CircleCheck.tsx b/frontend/packages/ui/src/components/Icon/component/CircleCheck.tsx similarity index 93% rename from frontend/src/components/Icon/component/CircleCheck.tsx rename to frontend/packages/ui/src/components/Icon/component/CircleCheck.tsx index d06d7a6d..7f6a9f89 100644 --- a/frontend/src/components/Icon/component/CircleCheck.tsx +++ b/frontend/packages/ui/src/components/Icon/component/CircleCheck.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const CircleCheck = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="circle-check icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_787_13421" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_787_13421" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_787_13421)"> diff --git a/frontend/src/components/Icon/component/Clock.tsx b/frontend/packages/ui/src/components/Icon/component/Clock.tsx similarity index 91% rename from frontend/src/components/Icon/component/Clock.tsx rename to frontend/packages/ui/src/components/Icon/component/Clock.tsx index 994f238d..0a1d6713 100644 --- a/frontend/src/components/Icon/component/Clock.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Clock.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const Clock = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="clock icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_837" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_837" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_837)"> diff --git a/frontend/src/components/Icon/custom/ClockGraphic.tsx b/frontend/packages/ui/src/components/Icon/component/ClockGraphic.tsx similarity index 100% rename from frontend/src/components/Icon/custom/ClockGraphic.tsx rename to frontend/packages/ui/src/components/Icon/component/ClockGraphic.tsx diff --git a/frontend/src/components/Icon/component/Close.tsx b/frontend/packages/ui/src/components/Icon/component/Close.tsx similarity index 94% rename from frontend/src/components/Icon/component/Close.tsx rename to frontend/packages/ui/src/components/Icon/component/Close.tsx index 6c571080..d8e88dd0 100644 --- a/frontend/src/components/Icon/component/Close.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Close.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const Close = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="close icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_197" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_197" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_197)"> diff --git a/frontend/src/components/Icon/component/Google.tsx b/frontend/packages/ui/src/components/Icon/component/Google.tsx similarity index 100% rename from frontend/src/components/Icon/component/Google.tsx rename to frontend/packages/ui/src/components/Icon/component/Google.tsx diff --git a/frontend/src/components/Icon/component/GoogleCalendar.tsx b/frontend/packages/ui/src/components/Icon/component/GoogleCalendar.tsx similarity index 100% rename from frontend/src/components/Icon/component/GoogleCalendar.tsx rename to frontend/packages/ui/src/components/Icon/component/GoogleCalendar.tsx diff --git a/frontend/src/components/Icon/component/IconDotsMono.tsx b/frontend/packages/ui/src/components/Icon/component/IconDotsMono.tsx similarity index 94% rename from frontend/src/components/Icon/component/IconDotsMono.tsx rename to frontend/packages/ui/src/components/Icon/component/IconDotsMono.tsx index e946e8cd..59bf76e1 100644 --- a/frontend/src/components/Icon/component/IconDotsMono.tsx +++ b/frontend/packages/ui/src/components/Icon/component/IconDotsMono.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const IconDotsMono = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="icon-dots-mono icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_481" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_481" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_481)"> diff --git a/frontend/src/components/Icon/component/Logo.tsx b/frontend/packages/ui/src/components/Icon/component/Logo.tsx similarity index 98% rename from frontend/src/components/Icon/component/Logo.tsx rename to frontend/packages/ui/src/components/Icon/component/Logo.tsx index d3e5b2f3..71465bb9 100644 --- a/frontend/src/components/Icon/component/Logo.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Logo.tsx @@ -16,11 +16,11 @@ export const Logo = ({ clickable = false, className, width = 24, height = 24 , f <path d="M65.9218 4.78264C65.9218 4.17016 66.4663 3.91377 66.8573 3.91377C67.2483 3.91377 67.7929 4.17016 67.7929 4.78264V7.09011H68.9519C69.4546 7.09011 69.7338 7.43196 69.7338 7.91624C69.7338 8.40053 69.4546 8.74238 68.9519 8.74238H67.7929V12.9443C67.7929 13.5567 67.2483 13.8131 66.8573 13.8131C66.4663 13.8131 65.9218 13.5567 65.9218 12.9443V4.78264ZM57.4598 5.60877C57.4598 5.05327 57.9066 4.71142 58.5071 4.71142H63.143C63.7434 4.71142 64.1903 5.05327 64.1903 5.60877V9.79641C64.1903 10.4659 64.0227 10.765 63.52 11.0071C62.9894 11.2635 62.0538 11.4344 60.825 11.4344C59.5962 11.4344 58.6607 11.2635 58.158 11.0214C57.6273 10.765 57.4598 10.4659 57.4598 9.79641V5.60877ZM58.9818 13.4285C58.9818 12.8161 59.5264 12.5597 59.9174 12.5597C60.3084 12.5597 60.8529 12.8161 60.8529 13.4285V15.9354C60.8529 16.0636 60.9367 16.1491 61.0624 16.1491H67.3879C67.8906 16.1491 68.1699 16.4909 68.1699 16.9752C68.1699 17.4595 67.8906 17.8014 67.3879 17.8014H60.1827C59.4985 17.8014 58.9818 17.3028 58.9818 16.6476V13.4285ZM59.303 9.52578C59.303 9.597 59.3449 9.65397 59.4147 9.66822C59.7498 9.76792 60.2246 9.81065 60.825 9.81065C61.4255 9.81065 61.9002 9.76792 62.2354 9.66822C62.3052 9.65397 62.3471 9.597 62.3471 9.52578V6.3352H59.303V9.52578Z" fill="#333D48"/> <path d="M53.7441 4.78264C53.7441 4.17016 54.2887 3.91377 54.6657 3.91377C55.0707 3.91377 55.5873 4.17016 55.5873 4.78264V17.1889C55.5873 17.8014 55.0707 18.0577 54.6657 18.0577C54.2887 18.0577 53.7441 17.8014 53.7441 17.1889V4.78264ZM49.1501 9.01301C49.1501 8.52872 49.4294 8.18687 49.9321 8.18687H50.8537V4.93932C50.8537 4.32684 51.3982 4.07045 51.7753 4.07045C52.1802 4.07045 52.6969 4.32684 52.6969 4.93932V16.9325C52.6969 17.545 52.1802 17.8014 51.7753 17.8014C51.3982 17.8014 50.8537 17.545 50.8537 16.9325V9.83914H49.9321C49.4294 9.83914 49.1501 9.49729 49.1501 9.01301Z" fill="#333D48"/> <path d="M40.1741 4.78264C40.1741 4.17016 40.7187 3.91377 41.1097 3.91377C41.5007 3.91377 42.0453 4.17016 42.0453 4.78264V12.9443C42.0453 13.5567 41.5007 13.8131 41.1097 13.8131C40.7187 13.8131 40.1741 13.5567 40.1741 12.9443V8.88481H38.3589C37.9958 10.4659 36.5994 11.5769 34.7283 11.5769C32.55 11.5769 31 10.067 31 8.05868C31 6.03608 32.55 4.54049 34.7283 4.54049C36.5994 4.54049 38.0098 5.63726 38.3589 7.23255H40.1741V4.78264ZM32.8432 8.05868C32.8432 9.15544 33.6671 9.96733 34.7283 9.96733C35.7895 9.96733 36.6134 9.15544 36.6134 8.05868C36.6134 6.96192 35.7895 6.15003 34.7283 6.15003C33.6671 6.15003 32.8432 6.96192 32.8432 8.05868ZM33.0108 13.4285C33.0108 12.8161 33.5554 12.5597 33.9463 12.5597C34.3373 12.5597 34.8819 12.8161 34.8819 13.4285V15.9354C34.8819 16.0636 34.9657 16.1491 35.0914 16.1491H41.5565C42.0592 16.1491 42.3385 16.4909 42.3385 16.9752C42.3385 17.4595 42.0592 17.8014 41.5565 17.8014H34.2116C33.5274 17.8014 33.0108 17.3028 33.0108 16.6476V13.4285Z" fill="#333D48"/> -<mask id="path9Inside1_792_13815" fill="white"> +<mask id="path-9Inside-1_792_13815" fill="white"> <path fillRule="evenodd" clipRule="evenodd" d="M44.7025 5.07832C44.2698 5.07832 43.9191 5.42906 43.9191 5.86173C43.9191 6.29439 44.2698 6.64513 44.7025 6.64513H45.9142V7.32867C45.9142 8.14056 45.8584 9.00942 45.6629 9.67887C45.3557 10.7471 44.8669 11.4736 43.9593 12.4849C43.7778 12.6843 43.5963 12.9407 43.5963 13.2968C43.5963 13.6386 43.9035 14.0089 44.3643 14.0089C44.8111 14.0089 45.1043 13.8238 45.4255 13.4677C46.1935 12.5846 46.5845 11.8012 46.8498 10.9466C47.0872 11.8012 47.5899 12.7413 48.2462 13.4677C48.5813 13.838 48.8745 14.0089 49.3214 14.0089C49.8101 14.0089 50.0894 13.6386 50.0894 13.2968C50.0894 12.9407 49.9078 12.7128 49.7263 12.4849C48.9304 11.4878 48.3299 10.7329 48.0227 9.67887C47.8272 9.00942 47.7714 8.14056 47.7714 7.32867V6.64513H49.0004C49.4331 6.64513 49.7838 6.29439 49.7838 5.86173C49.7838 5.42906 49.4331 5.07832 49.0004 5.07832H44.7025Z"/> </mask> <path fillRule="evenodd" clipRule="evenodd" d="M44.7025 5.07832C44.2698 5.07832 43.9191 5.42906 43.9191 5.86173C43.9191 6.29439 44.2698 6.64513 44.7025 6.64513H45.9142V7.32867C45.9142 8.14056 45.8584 9.00942 45.6629 9.67887C45.3557 10.7471 44.8669 11.4736 43.9593 12.4849C43.7778 12.6843 43.5963 12.9407 43.5963 13.2968C43.5963 13.6386 43.9035 14.0089 44.3643 14.0089C44.8111 14.0089 45.1043 13.8238 45.4255 13.4677C46.1935 12.5846 46.5845 11.8012 46.8498 10.9466C47.0872 11.8012 47.5899 12.7413 48.2462 13.4677C48.5813 13.838 48.8745 14.0089 49.3214 14.0089C49.8101 14.0089 50.0894 13.6386 50.0894 13.2968C50.0894 12.9407 49.9078 12.7128 49.7263 12.4849C48.9304 11.4878 48.3299 10.7329 48.0227 9.67887C47.8272 9.00942 47.7714 8.14056 47.7714 7.32867V6.64513H49.0004C49.4331 6.64513 49.7838 6.29439 49.7838 5.86173C49.7838 5.42906 49.4331 5.07832 49.0004 5.07832H44.7025Z" fill="#333D48"/> -<path d="M45.9142 6.64513H46.9142V5.64513H45.9142V6.64513ZM45.6629 9.67887L44.703 9.39856L44.7018 9.40251L45.6629 9.67887ZM43.9593 12.4849L44.6988 13.1581L44.7035 13.1528L43.9593 12.4849ZM45.4255 13.4677L46.1681 14.1374L46.1741 14.1307L46.1801 14.1239L45.4255 13.4677ZM46.8498 10.9466L47.8133 10.6789L46.9031 7.40198L45.8947 10.6501L46.8498 10.9466ZM48.2462 13.4677L47.5041 14.1381L47.5047 14.1387L48.2462 13.4677ZM49.7263 12.4849L50.5085 11.8618L50.5078 11.861L49.7263 12.4849ZM48.0227 9.67887L48.9828 9.39906L48.9826 9.39856L48.0227 9.67887ZM47.7714 6.64513V5.64513H46.7714V6.64513H47.7714ZM44.9191 5.86173C44.9191 5.98135 44.8221 6.07832 44.7025 6.07832V4.07832C43.7175 4.07832 42.9191 4.87678 42.9191 5.86173H44.9191ZM44.7025 5.64513C44.8221 5.64513 44.9191 5.7421 44.9191 5.86173H42.9191C42.9191 6.84667 43.7175 7.64513 44.7025 7.64513V5.64513ZM45.9142 5.64513H44.7025V7.64513H45.9142V5.64513ZM44.9142 6.64513V7.32867H46.9142V6.64513H44.9142ZM44.9142 7.32867C44.9142 8.11922 44.8571 8.87086 44.703 9.39856L46.6228 9.95918C46.8597 9.14798 46.9142 8.16189 46.9142 7.32867H44.9142ZM44.7018 9.40251C44.4531 10.2673 44.0721 10.862 43.2151 11.8169L44.7035 13.1528C45.6618 12.0851 46.2582 11.227 46.6239 9.95524L44.7018 9.40251ZM43.2198 11.8117C42.9818 12.0731 42.5963 12.5651 42.5963 13.2968H44.5963C44.5963 13.294 44.5964 13.2948 44.596 13.2967C44.5956 13.2981 44.5962 13.295 44.6006 13.2862C44.6115 13.2649 44.6369 13.2261 44.6988 13.158L43.2198 11.8117ZM42.5963 13.2968C42.5963 14.1845 43.3448 15.0089 44.3643 15.0089V13.0089C44.4145 13.0089 44.4739 13.033 44.517 13.0763C44.5487 13.1083 44.5963 13.1767 44.5963 13.2968H42.5963ZM44.3643 15.0089C44.7383 15.0089 45.0879 14.9284 45.4146 14.7531C45.7258 14.5862 45.9686 14.3586 46.1681 14.1374L44.6829 12.7979C44.5612 12.9328 44.4968 12.9759 44.4694 12.9906C44.4579 12.9967 44.4494 12.9998 44.4397 13.0022C44.429 13.0047 44.4061 13.0089 44.3643 13.0089V15.0089ZM46.1801 14.1239C47.0445 13.1299 47.5009 12.2222 47.8048 11.243L45.8947 10.6501C45.6681 11.3802 45.3425 12.0392 44.6709 12.8115L46.1801 14.1239ZM45.8863 11.2142C46.1648 12.2171 46.7428 13.2954 47.5041 14.1381L48.9882 12.7973C48.4369 12.1871 48.0095 11.3853 47.8133 10.6789L45.8863 11.2142ZM47.5047 14.1387C47.7139 14.3698 47.963 14.5992 48.2802 14.7643C48.6122 14.937 48.9592 15.0089 49.3214 15.0089V13.0089C49.2367 13.0089 49.2137 12.9954 49.2035 12.9901C49.1786 12.9772 49.1136 12.9359 48.9876 12.7967L47.5047 14.1387ZM49.3214 15.0089C50.3933 15.0089 51.0894 14.1594 51.0894 13.2968H49.0894C49.0894 13.2034 49.1238 13.1334 49.1699 13.0857C49.1957 13.0591 49.2264 13.0381 49.2587 13.0244C49.2916 13.0105 49.3153 13.0089 49.3214 13.0089V15.0089ZM51.0894 13.2968C51.0894 12.5517 50.6761 12.0722 50.5085 11.8618L48.9441 13.1079C48.992 13.168 49.0223 13.2064 49.0486 13.2428C49.0732 13.2769 49.0847 13.2964 49.09 13.3067C49.0947 13.3156 49.0934 13.3149 49.0915 13.3076C49.0893 13.2996 49.0894 13.2946 49.0894 13.2968H51.0894ZM50.5078 11.861C49.6927 10.8399 49.226 10.2334 48.9828 9.39906L47.0627 9.95869C47.4339 11.2324 48.1681 12.1357 48.9448 13.1088L50.5078 11.861ZM48.9826 9.39856C48.8285 8.87086 48.7714 8.11922 48.7714 7.32867H46.7714C46.7714 8.16189 46.8259 9.14798 47.0628 9.95918L48.9826 9.39856ZM48.7714 7.32867V6.64513H46.7714V7.32867H48.7714ZM49.0004 5.64513H47.7714V7.64513H49.0004V5.64513ZM48.7838 5.86173C48.7838 5.7421 48.8808 5.64513 49.0004 5.64513V7.64513C49.9854 7.64513 50.7838 6.84667 50.7838 5.86173H48.7838ZM49.0004 6.07832C48.8808 6.07832 48.7838 5.98135 48.7838 5.86173H50.7838C50.7838 4.87678 49.9854 4.07832 49.0004 4.07832V6.07832ZM44.7025 6.07832H49.0004V4.07832H44.7025V6.07832Z" fill="#333D48" mask="url(#path9Inside1_792_13815)"/> +<path d="M45.9142 6.64513H46.9142V5.64513H45.9142V6.64513ZM45.6629 9.67887L44.703 9.39856L44.7018 9.40251L45.6629 9.67887ZM43.9593 12.4849L44.6988 13.1581L44.7035 13.1528L43.9593 12.4849ZM45.4255 13.4677L46.1681 14.1374L46.1741 14.1307L46.1801 14.1239L45.4255 13.4677ZM46.8498 10.9466L47.8133 10.6789L46.9031 7.40198L45.8947 10.6501L46.8498 10.9466ZM48.2462 13.4677L47.5041 14.1381L47.5047 14.1387L48.2462 13.4677ZM49.7263 12.4849L50.5085 11.8618L50.5078 11.861L49.7263 12.4849ZM48.0227 9.67887L48.9828 9.39906L48.9826 9.39856L48.0227 9.67887ZM47.7714 6.64513V5.64513H46.7714V6.64513H47.7714ZM44.9191 5.86173C44.9191 5.98135 44.8221 6.07832 44.7025 6.07832V4.07832C43.7175 4.07832 42.9191 4.87678 42.9191 5.86173H44.9191ZM44.7025 5.64513C44.8221 5.64513 44.9191 5.7421 44.9191 5.86173H42.9191C42.9191 6.84667 43.7175 7.64513 44.7025 7.64513V5.64513ZM45.9142 5.64513H44.7025V7.64513H45.9142V5.64513ZM44.9142 6.64513V7.32867H46.9142V6.64513H44.9142ZM44.9142 7.32867C44.9142 8.11922 44.8571 8.87086 44.703 9.39856L46.6228 9.95918C46.8597 9.14798 46.9142 8.16189 46.9142 7.32867H44.9142ZM44.7018 9.40251C44.4531 10.2673 44.0721 10.862 43.2151 11.8169L44.7035 13.1528C45.6618 12.0851 46.2582 11.227 46.6239 9.95524L44.7018 9.40251ZM43.2198 11.8117C42.9818 12.0731 42.5963 12.5651 42.5963 13.2968H44.5963C44.5963 13.294 44.5964 13.2948 44.596 13.2967C44.5956 13.2981 44.5962 13.295 44.6006 13.2862C44.6115 13.2649 44.6369 13.2261 44.6988 13.158L43.2198 11.8117ZM42.5963 13.2968C42.5963 14.1845 43.3448 15.0089 44.3643 15.0089V13.0089C44.4145 13.0089 44.4739 13.033 44.517 13.0763C44.5487 13.1083 44.5963 13.1767 44.5963 13.2968H42.5963ZM44.3643 15.0089C44.7383 15.0089 45.0879 14.9284 45.4146 14.7531C45.7258 14.5862 45.9686 14.3586 46.1681 14.1374L44.6829 12.7979C44.5612 12.9328 44.4968 12.9759 44.4694 12.9906C44.4579 12.9967 44.4494 12.9998 44.4397 13.0022C44.429 13.0047 44.4061 13.0089 44.3643 13.0089V15.0089ZM46.1801 14.1239C47.0445 13.1299 47.5009 12.2222 47.8048 11.243L45.8947 10.6501C45.6681 11.3802 45.3425 12.0392 44.6709 12.8115L46.1801 14.1239ZM45.8863 11.2142C46.1648 12.2171 46.7428 13.2954 47.5041 14.1381L48.9882 12.7973C48.4369 12.1871 48.0095 11.3853 47.8133 10.6789L45.8863 11.2142ZM47.5047 14.1387C47.7139 14.3698 47.963 14.5992 48.2802 14.7643C48.6122 14.937 48.9592 15.0089 49.3214 15.0089V13.0089C49.2367 13.0089 49.2137 12.9954 49.2035 12.9901C49.1786 12.9772 49.1136 12.9359 48.9876 12.7967L47.5047 14.1387ZM49.3214 15.0089C50.3933 15.0089 51.0894 14.1594 51.0894 13.2968H49.0894C49.0894 13.2034 49.1238 13.1334 49.1699 13.0857C49.1957 13.0591 49.2264 13.0381 49.2587 13.0244C49.2916 13.0105 49.3153 13.0089 49.3214 13.0089V15.0089ZM51.0894 13.2968C51.0894 12.5517 50.6761 12.0722 50.5085 11.8618L48.9441 13.1079C48.992 13.168 49.0223 13.2064 49.0486 13.2428C49.0732 13.2769 49.0847 13.2964 49.09 13.3067C49.0947 13.3156 49.0934 13.3149 49.0915 13.3076C49.0893 13.2996 49.0894 13.2946 49.0894 13.2968H51.0894ZM50.5078 11.861C49.6927 10.8399 49.226 10.2334 48.9828 9.39906L47.0627 9.95869C47.4339 11.2324 48.1681 12.1357 48.9448 13.1088L50.5078 11.861ZM48.9826 9.39856C48.8285 8.87086 48.7714 8.11922 48.7714 7.32867H46.7714C46.7714 8.16189 46.8259 9.14798 47.0628 9.95918L48.9826 9.39856ZM48.7714 7.32867V6.64513H46.7714V7.32867H48.7714ZM49.0004 5.64513H47.7714V7.64513H49.0004V5.64513ZM48.7838 5.86173C48.7838 5.7421 48.8808 5.64513 49.0004 5.64513V7.64513C49.9854 7.64513 50.7838 6.84667 50.7838 5.86173H48.7838ZM49.0004 6.07832C48.8808 6.07832 48.7838 5.98135 48.7838 5.86173H50.7838C50.7838 4.87678 49.9854 4.07832 49.0004 4.07832V6.07832ZM44.7025 6.07832H49.0004V4.07832H44.7025V6.07832Z" fill="#333D48" mask="url(#path-9Inside-1_792_13815)"/> <defs> <filter id="filter0_d_792_13815" x="0" y="1" width="30" height="14" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> <feFlood floodOpacity="0" result="BackgroundImageFix"/> diff --git a/frontend/src/components/Icon/component/Pencil.tsx b/frontend/packages/ui/src/components/Icon/component/Pencil.tsx similarity index 92% rename from frontend/src/components/Icon/component/Pencil.tsx rename to frontend/packages/ui/src/components/Icon/component/Pencil.tsx index 5303d679..4ba57719 100644 --- a/frontend/src/components/Icon/component/Pencil.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Pencil.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const Pencil = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="pencil icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_730_15520" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_730_15520" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_730_15520)"> diff --git a/frontend/src/components/Icon/component/PinLocation.tsx b/frontend/packages/ui/src/components/Icon/component/PinLocation.tsx similarity index 89% rename from frontend/src/components/Icon/component/PinLocation.tsx rename to frontend/packages/ui/src/components/Icon/component/PinLocation.tsx index 932368d0..c835ef73 100644 --- a/frontend/src/components/Icon/component/PinLocation.tsx +++ b/frontend/packages/ui/src/components/Icon/component/PinLocation.tsx @@ -4,11 +4,11 @@ import type { IconProps } from '../Icon.d.ts'; export const PinLocation = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="pin-location icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_606" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_606" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_606)"> -<path fillRule="evenodd" clipRule="evenodd" d="M10.9455 12.5506C10.6102 12.4174 10.3049 12.2186 10.0473 11.9659C9.78976 11.7133 9.58516 11.4118 9.44549 11.0791C9.30583 10.7465 9.23389 10.3893 9.23389 10.0285C9.23389 9.66772 9.30583 9.31055 9.44549 8.97789C9.58516 8.64523 9.78976 8.34375 10.0473 8.09109C10.3049 7.83843 10.6102 7.63967 10.9455 7.50641C11.2808 7.37315 11.6393 7.30809 12 7.31501C12.7106 7.32865 13.3876 7.62054 13.8853 8.12793C14.3831 8.63532 14.6619 9.31773 14.6619 10.0285C14.6619 10.7393 14.3831 11.4217 13.8853 11.9291C13.3876 12.4365 12.7106 12.7284 12 12.742C11.6393 12.7489 11.2808 12.6839 10.9455 12.5506ZM9.87599 1.03701C5.60599 1.99001 2.61198 6.00901 2.77998 10.381C2.91198 13.815 5.01898 16.887 11.288 22.914C11.684 23.294 12.318 23.296 12.715 22.915C19.199 16.682 21.231 13.61 21.231 10.028C21.231 4.23801 15.898 0.306989 9.87599 1.03801" fill="#8B95A1"/> +<path fillRule="evenodd" clipRule="evenodd" d="M10.9455 12.5506C10.6102 12.4174 10.3049 12.2186 10.0473 11.9659C9.78976 11.7133 9.58516 11.4118 9.44549 11.0791C9.30583 10.7465 9.23389 10.3893 9.23389 10.0285C9.23389 9.66772 9.30583 9.31055 9.44549 8.97789C9.58516 8.64523 9.78976 8.34375 10.0473 8.09109C10.3049 7.83843 10.6102 7.63967 10.9455 7.50641C11.2808 7.37315 11.6393 7.30809 12 7.31501C12.7106 7.32865 13.3876 7.62054 13.8853 8.12793C14.3831 8.63532 14.6619 9.31773 14.6619 10.0285C14.6619 10.7393 14.3831 11.4217 13.8853 11.9291C13.3876 12.4365 12.7106 12.7284 12 12.742C11.6393 12.7489 11.2808 12.6839 10.9455 12.5506ZM9.87599 1.03701C5.60599 1.99001 2.61198 6.00901 2.77998 10.381C2.91198 13.815 5.01898 16.887 11.288 22.914C11.684 23.294 12.318 23.296 12.715 22.915C19.199 16.682 21.231 13.61 21.231 10.028C21.231 4.23801 15.898 -0.306989 9.87599 1.03801" fill="#8B95A1"/> </g> </svg> diff --git a/frontend/src/components/Icon/component/Plus.tsx b/frontend/packages/ui/src/components/Icon/component/Plus.tsx similarity index 100% rename from frontend/src/components/Icon/component/Plus.tsx rename to frontend/packages/ui/src/components/Icon/component/Plus.tsx diff --git a/frontend/src/components/Icon/component/Progress.tsx b/frontend/packages/ui/src/components/Icon/component/Progress.tsx similarity index 97% rename from frontend/src/components/Icon/component/Progress.tsx rename to frontend/packages/ui/src/components/Icon/component/Progress.tsx index b9bbf415..f808796f 100644 --- a/frontend/src/components/Icon/component/Progress.tsx +++ b/frontend/packages/ui/src/components/Icon/component/Progress.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const Progress = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" aria-label="progress icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_4590_3525" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"> +<mask id="mask0_4590_3525" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"> <rect width="16" height="16" fill="white"/> </mask> <g mask="url(#mask0_4590_3525)"> diff --git a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx similarity index 57% rename from frontend/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx rename to frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx index 46a1aabf..aa362a5f 100644 --- a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx +++ b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowDown.tsx @@ -1,8 +1,17 @@ export const TooltipArrowDown = ({ fill }: { fill: string }) => ( - <svg fill='none' height='9' viewBox='0 0 15 9' width='15' xmlns='http://www.w3.org/2000/svg'> + <svg + fill='none' + height='9' + viewBox='0 0 15 9' + width='15' + xmlns='http://www.w3.org/2000/svg' + > <g filter='url(#filter0_b_765_1178)'> - <path d='M9.13847 7.65933C8.34226 8.79677 6.65774 8.79677 - 5.86154 7.65934L0.5 -1.22392e-06L14.5 0L9.13847 7.65933Z' fill={fill}/> + <path + d='M9.13847 7.65933C8.34226 8.79677 6.65774 8.79677 + 5.86154 7.65934L0.5 -1.22392e-06L14.5 0L9.13847 7.65933Z' + fill={fill} + /> </g> <defs> <filter @@ -12,15 +21,21 @@ export const TooltipArrowDown = ({ fill }: { fill: string }) => ( id='filter0_b_765_1178' width='24' x='-4.5' - y='-5'> - <feFlood floodOpacity='0' result='BackgroundImageFix'/> - <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5'/> - <feComposite in2='SourceAlpha' operator='in' result='effect1_backgroundBlur_765_1178'/> + y='-5' + > + <feFlood floodOpacity='0' result='BackgroundImageFix' /> + <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5' /> + <feComposite + in2='SourceAlpha' + operator='in' + result='effect1_backgroundBlur_765_1178' + /> <feBlend in='SourceGraphic' in2='effect1_backgroundBlur_765_1178' mode='normal' - result='shape'/> + result='shape' + /> </filter> </defs> </svg> diff --git a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx similarity index 52% rename from frontend/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx rename to frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx index d8fd91ef..09673f2a 100644 --- a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx +++ b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowLeft.tsx @@ -1,8 +1,17 @@ export const TooltipArrowLeft = ({ fill }: { fill: string }) => ( - <svg fill='none' height='15' viewBox='0 0 9 15' width='9' xmlns='http://www.w3.org/2000/svg'> + <svg + fill='none' + height='15' + viewBox='0 0 9 15' + width='9' + xmlns='http://www.w3.org/2000/svg' + > <g filter='url(#filter0_b_765_1124)'> - <path d='M1.34066 9.13847C0.203231 8.34226 0.203229 6.65774 - 1.34066 5.86154L9 0.499999L9 14.5L1.34066 9.13847Z' fill={fill}/> + <path + d='M1.34066 9.13847C0.203231 8.34226 0.203229 6.65774 + 1.34066 5.86154L9 0.499999L9 14.5L1.34066 9.13847Z' + fill={fill} + /> </g> <defs> <filter @@ -12,15 +21,21 @@ export const TooltipArrowLeft = ({ fill }: { fill: string }) => ( id='filter0_b_765_1124' width='18.5124' x='-4.51242' - y='-4.5'> - <feFlood floodOpacity='0' result='BackgroundImageFix'/> - <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5'/> - <feComposite in2='SourceAlpha' operator='in' result='effect1_backgroundBlur_765_1124'/> + y='-4.5' + > + <feFlood floodOpacity='0' result='BackgroundImageFix' /> + <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5' /> + <feComposite + in2='SourceAlpha' + operator='in' + result='effect1_backgroundBlur_765_1124' + /> <feBlend in='SourceGraphic' in2='effect1_backgroundBlur_765_1124' mode='normal' - result='shape'/> + result='shape' + /> </filter> </defs> </svg> diff --git a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx similarity index 57% rename from frontend/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx rename to frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx index da0e6f01..57a2686e 100644 --- a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx +++ b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowRight.tsx @@ -1,8 +1,17 @@ export const TooltipArrowRight = ({ fill }: { fill: string }) => ( - <svg fill='none' height='15' viewBox='0 0 9 15' width='9' xmlns='http://www.w3.org/2000/svg'> + <svg + fill='none' + height='15' + viewBox='0 0 9 15' + width='9' + xmlns='http://www.w3.org/2000/svg' + > <g filter='url(#filter0_b_765_975)'> - <path d='M7.65933 5.86153C8.79677 6.65774 8.79677 8.34226 - 7.65934 9.13846L-6.11959e-07 14.5L0 0.5L7.65933 5.86153Z' fill={fill}/> + <path + d='M7.65933 5.86153C8.79677 6.65774 8.79677 8.34226 + 7.65934 9.13846L-6.11959e-07 14.5L0 0.5L7.65933 5.86153Z' + fill={fill} + /> </g> <defs> <filter @@ -12,15 +21,21 @@ export const TooltipArrowRight = ({ fill }: { fill: string }) => ( id='filter0_b_765_975' width='18.5124' x='-5' - y='-4.5'> - <feFlood floodOpacity='0' result='BackgroundImageFix'/> - <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5'/> - <feComposite in2='SourceAlpha' operator='in' result='effect1_backgroundBlur_765_975'/> + y='-4.5' + > + <feFlood floodOpacity='0' result='BackgroundImageFix' /> + <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5' /> + <feComposite + in2='SourceAlpha' + operator='in' + result='effect1_backgroundBlur_765_975' + /> <feBlend in='SourceGraphic' in2='effect1_backgroundBlur_765_975' mode='normal' - result='shape'/> + result='shape' + /> </filter> </defs> </svg> diff --git a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx similarity index 52% rename from frontend/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx rename to frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx index 35eebfbd..68d1a900 100644 --- a/frontend/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx +++ b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/TooltipArrowUp.tsx @@ -1,8 +1,17 @@ export const TooltipArrowUp = ({ fill }: { fill: string }) => ( - <svg fill='none' height='9' viewBox='0 0 15 9' width='15' xmlns='http://www.w3.org/2000/svg'> + <svg + fill='none' + height='9' + viewBox='0 0 15 9' + width='15' + xmlns='http://www.w3.org/2000/svg' + > <g filter='url(#filter0_b_765_1187)'> - <path d='M5.86153 1.34066C6.65774 0.203231 8.34226 0.203229 - 9.13846 1.34066L14.5 9H0.5L5.86153 1.34066Z' fill={fill}/> + <path + d='M5.86153 1.34066C6.65774 0.203231 8.34226 0.203229 + 9.13846 1.34066L14.5 9H0.5L5.86153 1.34066Z' + fill={fill} + /> </g> <defs> <filter @@ -12,15 +21,21 @@ export const TooltipArrowUp = ({ fill }: { fill: string }) => ( id='filter0_b_765_1187' width='24' x='-4.5' - y='-4.51245'> - <feFlood floodOpacity='0' result='BackgroundImageFix'/> - <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5'/> - <feComposite in2='SourceAlpha' operator='in' result='effect1_backgroundBlur_765_1187'/> + y='-4.51245' + > + <feFlood floodOpacity='0' result='BackgroundImageFix' /> + <feGaussianBlur in='BackgroundImageFix' stdDeviation='2.5' /> + <feComposite + in2='SourceAlpha' + operator='in' + result='effect1_backgroundBlur_765_1187' + /> <feBlend in='SourceGraphic' in2='effect1_backgroundBlur_765_1187' mode='normal' - result='shape'/> + result='shape' + /> </filter> </defs> </svg> diff --git a/frontend/src/components/Icon/component/TooltipArrow/index.ts b/frontend/packages/ui/src/components/Icon/component/TooltipArrow/index.ts similarity index 100% rename from frontend/src/components/Icon/component/TooltipArrow/index.ts rename to frontend/packages/ui/src/components/Icon/component/TooltipArrow/index.ts diff --git a/frontend/src/components/Icon/component/TriangleWarning.tsx b/frontend/packages/ui/src/components/Icon/component/TriangleWarning.tsx similarity index 93% rename from frontend/src/components/Icon/component/TriangleWarning.tsx rename to frontend/packages/ui/src/components/Icon/component/TriangleWarning.tsx index 38cdf93e..2489a011 100644 --- a/frontend/src/components/Icon/component/TriangleWarning.tsx +++ b/frontend/packages/ui/src/components/Icon/component/TriangleWarning.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const TriangleWarning = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="triangle-warning icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_741_3885" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_741_3885" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_741_3885)"> diff --git a/frontend/src/components/Icon/component/UserTwo.tsx b/frontend/packages/ui/src/components/Icon/component/UserTwo.tsx similarity index 94% rename from frontend/src/components/Icon/component/UserTwo.tsx rename to frontend/packages/ui/src/components/Icon/component/UserTwo.tsx index 63b1dc97..d735a242 100644 --- a/frontend/src/components/Icon/component/UserTwo.tsx +++ b/frontend/packages/ui/src/components/Icon/component/UserTwo.tsx @@ -4,7 +4,7 @@ import type { IconProps } from '../Icon.d.ts'; export const UserTwo = ({ clickable = false, className, width = 24, height = 24 , fill = "white", ...rest }: IconProps) => { return ( <svg width={width} height={height || width} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="user-two icon" fill="none" className={className} style={{ cursor: clickable ? "pointer": "default", ...rest.style }} {...rest}> -<mask id="mask0_732_284" mask-type='luminance' maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> +<mask id="mask0_732_284" style={{ maskType: "luminance" }} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> <rect width="24" height="24" fill="white"/> </mask> <g mask="url(#mask0_732_284)"> diff --git a/frontend/packages/ui/src/components/Icon/index.ts b/frontend/packages/ui/src/components/Icon/index.ts new file mode 100644 index 00000000..bf31d0ec --- /dev/null +++ b/frontend/packages/ui/src/components/Icon/index.ts @@ -0,0 +1,24 @@ +export * from "./component/ArrowLeft"; +export * from "./component/CalendarCheck"; +export * from "./component/CalendarMini"; +export * from "./component/Calendar"; +export * from "./component/CheckGraphic"; +export * from "./component/Check"; +export * from "./component/ChevronDown"; +export * from "./component/ChevronLeft"; +export * from "./component/ChevronRight"; +export * from "./component/CircleCheck"; +export * from "./component/ClockGraphic"; +export * from "./component/Clock"; +export * from "./component/Close"; +export * from "./component/GoogleCalendar"; +export * from "./component/Google"; +export * from "./component/IconDotsMono"; +export * from "./component/Logo"; +export * from "./component/Pencil"; +export * from "./component/PinLocation"; +export * from "./component/Plus"; +export * from "./component/Progress"; +export * from "./component/TriangleWarning"; +export * from "./component/UserTwo"; +export * from "./component/TooltipArrow"; \ No newline at end of file diff --git a/frontend/src/components/Icon/svg/arrow-left.svg b/frontend/packages/ui/src/components/Icon/svg/arrow-left.svg similarity index 100% rename from frontend/src/components/Icon/svg/arrow-left.svg rename to frontend/packages/ui/src/components/Icon/svg/arrow-left.svg diff --git a/frontend/src/components/Icon/svg/calendar-check.svg b/frontend/packages/ui/src/components/Icon/svg/calendar-check.svg similarity index 100% rename from frontend/src/components/Icon/svg/calendar-check.svg rename to frontend/packages/ui/src/components/Icon/svg/calendar-check.svg diff --git a/frontend/packages/ui/src/components/Icon/svg/calendar-mini.svg b/frontend/packages/ui/src/components/Icon/svg/calendar-mini.svg new file mode 100644 index 00000000..17de188a --- /dev/null +++ b/frontend/packages/ui/src/components/Icon/svg/calendar-mini.svg @@ -0,0 +1,8 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.5 4V5.5M13.5 4V5.5M4 14.5V7C4 6.60218 4.15804 6.22064 4.43934 5.93934C4.72064 5.65804 5.10218 5.5 5.5 5.5H14.5C14.8978 5.5 15.2794 5.65804 15.5607 5.93934C15.842 6.22064 16 6.60218 16 7V14.5M4 14.5C4 14.8978 4.15804 15.2794 4.43934 15.5607C4.72064 15.842 5.10218 16 5.5 16H14.5C14.8978 16 15.2794 15.842 15.5607 15.5607C15.842 15.2794 16 14.8978 16 14.5M4 14.5V9.5C4 9.10218 4.15804 8.72064 4.43934 8.43934C4.72064 8.15804 5.10218 8 5.5 8H14.5C14.8978 8 15.2794 8.15804 15.5607 8.43934C15.842 8.72064 16 9.10218 16 9.5V14.5" stroke="#3182F6" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M7.875 10.8677C7.875 11.0748 7.70711 11.2427 7.5 11.2427C7.29289 11.2427 7.125 11.0748 7.125 10.8677C7.125 10.6606 7.29289 10.4927 7.5 10.4927C7.70711 10.4927 7.875 10.6606 7.875 10.8677Z" stroke="#3182F6" stroke-width="0.75"/> +<path d="M7.875 13.1943C7.875 13.4014 7.70711 13.5693 7.5 13.5693C7.29289 13.5693 7.125 13.4014 7.125 13.1943C7.125 12.9872 7.29289 12.8193 7.5 12.8193C7.70711 12.8193 7.875 12.9872 7.875 13.1943Z" stroke="#3182F6" stroke-width="0.75"/> +<path d="M12.875 10.8677C12.875 11.0748 12.7071 11.2427 12.5 11.2427C12.2929 11.2427 12.125 11.0748 12.125 10.8677C12.125 10.6606 12.2929 10.4927 12.5 10.4927C12.7071 10.4927 12.875 10.6606 12.875 10.8677Z" stroke="#3182F6" stroke-width="0.75"/> +<path d="M10.375 10.8677C10.375 11.0748 10.2071 11.2427 10 11.2427C9.79289 11.2427 9.625 11.0748 9.625 10.8677C9.625 10.6606 9.79289 10.4927 10 10.4927C10.2071 10.4927 10.375 10.6606 10.375 10.8677Z" stroke="#3182F6" stroke-width="0.75"/> +<path d="M10.375 13.1943C10.375 13.4014 10.2071 13.5693 10 13.5693C9.79289 13.5693 9.625 13.4014 9.625 13.1943C9.625 12.9872 9.79289 12.8193 10 12.8193C10.2071 12.8193 10.375 12.9872 10.375 13.1943Z" stroke="#3182F6" stroke-width="0.75"/> +</svg> diff --git a/frontend/src/components/Icon/svg/calendar.svg b/frontend/packages/ui/src/components/Icon/svg/calendar.svg similarity index 100% rename from frontend/src/components/Icon/svg/calendar.svg rename to frontend/packages/ui/src/components/Icon/svg/calendar.svg diff --git a/frontend/src/components/Icon/svg/check-graphic.svg b/frontend/packages/ui/src/components/Icon/svg/check-graphic.svg similarity index 100% rename from frontend/src/components/Icon/svg/check-graphic.svg rename to frontend/packages/ui/src/components/Icon/svg/check-graphic.svg diff --git a/frontend/src/components/Icon/svg/check.svg b/frontend/packages/ui/src/components/Icon/svg/check.svg similarity index 100% rename from frontend/src/components/Icon/svg/check.svg rename to frontend/packages/ui/src/components/Icon/svg/check.svg diff --git a/frontend/src/components/Icon/svg/chevron-down.svg b/frontend/packages/ui/src/components/Icon/svg/chevron-down.svg similarity index 100% rename from frontend/src/components/Icon/svg/chevron-down.svg rename to frontend/packages/ui/src/components/Icon/svg/chevron-down.svg diff --git a/frontend/src/components/Icon/svg/chevron-left.svg b/frontend/packages/ui/src/components/Icon/svg/chevron-left.svg similarity index 100% rename from frontend/src/components/Icon/svg/chevron-left.svg rename to frontend/packages/ui/src/components/Icon/svg/chevron-left.svg diff --git a/frontend/src/components/Icon/svg/chevron-right.svg b/frontend/packages/ui/src/components/Icon/svg/chevron-right.svg similarity index 100% rename from frontend/src/components/Icon/svg/chevron-right.svg rename to frontend/packages/ui/src/components/Icon/svg/chevron-right.svg diff --git a/frontend/src/components/Icon/svg/circle-check.svg b/frontend/packages/ui/src/components/Icon/svg/circle-check.svg similarity index 100% rename from frontend/src/components/Icon/svg/circle-check.svg rename to frontend/packages/ui/src/components/Icon/svg/circle-check.svg diff --git a/frontend/src/components/Icon/svg/clock-graphic.svg b/frontend/packages/ui/src/components/Icon/svg/clock-graphic.svg similarity index 100% rename from frontend/src/components/Icon/svg/clock-graphic.svg rename to frontend/packages/ui/src/components/Icon/svg/clock-graphic.svg diff --git a/frontend/src/components/Icon/svg/clock.svg b/frontend/packages/ui/src/components/Icon/svg/clock.svg similarity index 100% rename from frontend/src/components/Icon/svg/clock.svg rename to frontend/packages/ui/src/components/Icon/svg/clock.svg diff --git a/frontend/src/components/Icon/svg/close.svg b/frontend/packages/ui/src/components/Icon/svg/close.svg similarity index 100% rename from frontend/src/components/Icon/svg/close.svg rename to frontend/packages/ui/src/components/Icon/svg/close.svg diff --git a/frontend/src/components/Icon/svg/google-calendar.svg b/frontend/packages/ui/src/components/Icon/svg/google-calendar.svg similarity index 100% rename from frontend/src/components/Icon/svg/google-calendar.svg rename to frontend/packages/ui/src/components/Icon/svg/google-calendar.svg diff --git a/frontend/src/components/Icon/svg/google.svg b/frontend/packages/ui/src/components/Icon/svg/google.svg similarity index 100% rename from frontend/src/components/Icon/svg/google.svg rename to frontend/packages/ui/src/components/Icon/svg/google.svg diff --git a/frontend/src/components/Icon/svg/icon-dots-mono.svg b/frontend/packages/ui/src/components/Icon/svg/icon-dots-mono.svg similarity index 100% rename from frontend/src/components/Icon/svg/icon-dots-mono.svg rename to frontend/packages/ui/src/components/Icon/svg/icon-dots-mono.svg diff --git a/frontend/src/components/Icon/svg/logo.svg b/frontend/packages/ui/src/components/Icon/svg/logo.svg similarity index 100% rename from frontend/src/components/Icon/svg/logo.svg rename to frontend/packages/ui/src/components/Icon/svg/logo.svg diff --git a/frontend/src/components/Icon/svg/pencil.svg b/frontend/packages/ui/src/components/Icon/svg/pencil.svg similarity index 100% rename from frontend/src/components/Icon/svg/pencil.svg rename to frontend/packages/ui/src/components/Icon/svg/pencil.svg diff --git a/frontend/src/components/Icon/svg/pin-location.svg b/frontend/packages/ui/src/components/Icon/svg/pin-location.svg similarity index 100% rename from frontend/src/components/Icon/svg/pin-location.svg rename to frontend/packages/ui/src/components/Icon/svg/pin-location.svg diff --git a/frontend/src/components/Icon/svg/plus.svg b/frontend/packages/ui/src/components/Icon/svg/plus.svg similarity index 100% rename from frontend/src/components/Icon/svg/plus.svg rename to frontend/packages/ui/src/components/Icon/svg/plus.svg diff --git a/frontend/src/components/Icon/svg/progress.svg b/frontend/packages/ui/src/components/Icon/svg/progress.svg similarity index 100% rename from frontend/src/components/Icon/svg/progress.svg rename to frontend/packages/ui/src/components/Icon/svg/progress.svg diff --git a/frontend/src/components/Icon/svg/triangle-warning.svg b/frontend/packages/ui/src/components/Icon/svg/triangle-warning.svg similarity index 100% rename from frontend/src/components/Icon/svg/triangle-warning.svg rename to frontend/packages/ui/src/components/Icon/svg/triangle-warning.svg diff --git a/frontend/src/components/Icon/svg/user-two.svg b/frontend/packages/ui/src/components/Icon/svg/user-two.svg similarity index 100% rename from frontend/src/components/Icon/svg/user-two.svg rename to frontend/packages/ui/src/components/Icon/svg/user-two.svg diff --git a/frontend/src/components/Image/index.tsx b/frontend/packages/ui/src/components/Image/index.tsx similarity index 100% rename from frontend/src/components/Image/index.tsx rename to frontend/packages/ui/src/components/Image/index.tsx diff --git a/frontend/src/components/Input/Core/HelperText.tsx b/frontend/packages/ui/src/components/Input/Core/HelperText.tsx similarity index 100% rename from frontend/src/components/Input/Core/HelperText.tsx rename to frontend/packages/ui/src/components/Input/Core/HelperText.tsx diff --git a/frontend/src/components/Input/Core/InputField.tsx b/frontend/packages/ui/src/components/Input/Core/InputField.tsx similarity index 68% rename from frontend/src/components/Input/Core/InputField.tsx rename to frontend/packages/ui/src/components/Input/Core/InputField.tsx index 1b25ba34..fcae0ae6 100644 --- a/frontend/src/components/Input/Core/InputField.tsx +++ b/frontend/packages/ui/src/components/Input/Core/InputField.tsx @@ -1,21 +1,19 @@ +import { ChevronDown } from '@components/Icon'; +import { clsx } from '@endolphin/core/utils'; +import { vars } from '@endolphin/theme'; import type { InputHTMLAttributes } from 'react'; import { useRef } from 'react'; -import { ChevronDown } from '@/components/Icon'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import { vars } from '@/theme/index.css'; -import clsx from '@/utils/clsx'; - import { interactableBorderStyle } from '../index.css'; -import { InputContext } from '../InputContext'; +import { useInputContext } from '../InputContext'; import { inputFieldContainerStyle, inputFieldStyle, selectIconStyle } from './inputField.css'; -interface InputFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onClick'> { +export interface InputFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onClick'> { onClick?: () => void; } const InputField = ({ placeholder, onClick, ...inputProps }: InputFieldProps) => { - const { isValid, type, borderPlacement } = useSafeContext(InputContext); + const { isValid, type, borderPlacement } = useInputContext(); const inputRef = useRef<HTMLInputElement>(null); const handleContainerClick = () => { @@ -24,11 +22,11 @@ const InputField = ({ placeholder, onClick, ...inputProps }: InputFieldProps) => }; return ( - <div + <div className={clsx( - inputFieldContainerStyle({ type }), + inputFieldContainerStyle({ type }), borderPlacement === 'inputField' && interactableBorderStyle({ isValid }), - )} + )} onClick={handleContainerClick} > <input @@ -38,8 +36,8 @@ const InputField = ({ placeholder, onClick, ...inputProps }: InputFieldProps) => ref={inputRef} {...inputProps} /> - {type === 'select' && - <button + {type === 'select' && ( + <button className={selectIconStyle} tabIndex={-1} // tab으로 버튼 선택 안 되게 type='button' @@ -49,7 +47,8 @@ const InputField = ({ placeholder, onClick, ...inputProps }: InputFieldProps) => fill={vars.color.Ref.Netural[500]} width={20} /> - </button>} + </button> + )} </div> ); }; diff --git a/frontend/src/components/Input/Core/Label.tsx b/frontend/packages/ui/src/components/Input/Core/Label.tsx similarity index 100% rename from frontend/src/components/Input/Core/Label.tsx rename to frontend/packages/ui/src/components/Input/Core/Label.tsx diff --git a/frontend/src/components/Input/Core/helperText.css.ts b/frontend/packages/ui/src/components/Input/Core/helperText.css.ts similarity index 88% rename from frontend/src/components/Input/Core/helperText.css.ts rename to frontend/packages/ui/src/components/Input/Core/helperText.css.ts index 8f859338..c6137cab 100644 --- a/frontend/src/components/Input/Core/helperText.css.ts +++ b/frontend/packages/ui/src/components/Input/Core/helperText.css.ts @@ -1,7 +1,6 @@ +import { vars } from '@endolphin/theme'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const helperTextStyle = recipe({ base: {}, variants: { diff --git a/frontend/src/components/Input/Core/inputField.css.ts b/frontend/packages/ui/src/components/Input/Core/inputField.css.ts similarity index 92% rename from frontend/src/components/Input/Core/inputField.css.ts rename to frontend/packages/ui/src/components/Input/Core/inputField.css.ts index 6b598c72..90344304 100644 --- a/frontend/src/components/Input/Core/inputField.css.ts +++ b/frontend/packages/ui/src/components/Input/Core/inputField.css.ts @@ -1,9 +1,7 @@ +import { font, vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { font } from '@/theme/font'; -import { vars } from '@/theme/index.css'; - export const inputFieldContainerStyle = recipe({ base: { flex: 1, diff --git a/frontend/src/components/Input/Core/label.css.ts b/frontend/packages/ui/src/components/Input/Core/label.css.ts similarity index 88% rename from frontend/src/components/Input/Core/label.css.ts rename to frontend/packages/ui/src/components/Input/Core/label.css.ts index fa47dc45..567c5acd 100644 --- a/frontend/src/components/Input/Core/label.css.ts +++ b/frontend/packages/ui/src/components/Input/Core/label.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { vars } from '@/theme/index.css'; - export const labelContainerStyle = style({ display: 'inline-flex', gap: vars.spacing[50], diff --git a/frontend/src/components/Input/Input.stories.tsx b/frontend/packages/ui/src/components/Input/Input.stories.tsx similarity index 98% rename from frontend/src/components/Input/Input.stories.tsx rename to frontend/packages/ui/src/components/Input/Input.stories.tsx index 09f1b1a6..7a044076 100644 --- a/frontend/src/components/Input/Input.stories.tsx +++ b/frontend/packages/ui/src/components/Input/Input.stories.tsx @@ -1,7 +1,7 @@ +import { vars } from '@endolphin/theme'; import type { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; -import { vars } from '../../theme/index.css'; import { Check } from '../Icon'; import { Input } from './index'; diff --git a/frontend/src/components/Input/InputContext.ts b/frontend/packages/ui/src/components/Input/InputContext.ts similarity index 64% rename from frontend/src/components/Input/InputContext.ts rename to frontend/packages/ui/src/components/Input/InputContext.ts index 4bd6ae65..1e86d951 100644 --- a/frontend/src/components/Input/InputContext.ts +++ b/frontend/packages/ui/src/components/Input/InputContext.ts @@ -1,4 +1,4 @@ -import { createContext } from 'react'; +import { createStateContext } from '@endolphin/core/utils'; import type { CommonInputProps } from '.'; @@ -14,4 +14,7 @@ interface InputContextProps { * @Param {isValid} boolean - Input 컴포넌트가 Error 상태인지 여부. * @Param {type} InputProps['type'] - Input 컴포넌트의 타입. */ -export const InputContext = createContext<InputContextProps | null>(null); +export const { + StateProvider: InputProvider, + useContextState: useInputContext, +} = createStateContext<InputContextProps, InputContextProps, object>(); \ No newline at end of file diff --git a/frontend/src/components/Input/MultiInput.tsx b/frontend/packages/ui/src/components/Input/MultiInput.tsx similarity index 72% rename from frontend/src/components/Input/MultiInput.tsx rename to frontend/packages/ui/src/components/Input/MultiInput.tsx index c586fa2b..afeb2d12 100644 --- a/frontend/src/components/Input/MultiInput.tsx +++ b/frontend/packages/ui/src/components/Input/MultiInput.tsx @@ -1,54 +1,51 @@ +import { clsx, intersperseElement } from '@endolphin/core/utils'; import type { JSX, PropsWithChildren, ReactNode } from 'react'; import { isValidElement } from 'react'; -import clsx from '@/utils/clsx'; -import { intersperseElement } from '@/utils/jsx'; - import { Text } from '../Text'; import { type CommonInputProps, ICON_WIDTH } from '.'; import HelperText from './Core/HelperText'; import InputField from './Core/InputField'; import Label from './Core/Label'; -import { +import { containerStyle, inputFieldsContainerStyle, interactableBorderStyle, - separatorStyle, + separatorStyle, } from './index.css'; -import { InputContext } from './InputContext'; +import { InputProvider } from './InputContext'; export interface MultiInputProps extends CommonInputProps, PropsWithChildren { separator?: string | JSX.Element; borderPlacement?: 'container' | 'inputField'; } -export const MultiInput = ({ +export const MultiInput = ({ label, type = 'text', isValid = true, - required = false, + required = false, separator = '', - hint, - error, + hint, + error, borderPlacement = 'inputField', className, children, }: MultiInputProps) => { - const childElements = children ? - Array.from(children as ReactNode[]).filter(isValidElement) - : []; + const childElements = children ? Array.from(children as ReactNode[]).filter(isValidElement) : []; const separatorElement = prepareSeparatorLayout(separator); const childrenWithSeparators = childElements.length > 1 ? intersperseElement(childElements, separatorElement) : childElements; return ( - <InputContext.Provider value={{ isValid, type, borderPlacement }}> + <InputProvider initialValue={{ isValid, type, borderPlacement }}> <div className={clsx(className, containerStyle)}> <Label required={required}>{label}</Label> - <div className={clsx( - inputFieldsContainerStyle, - borderPlacement === 'container' && interactableBorderStyle({ isValid }), - )} + <div + className={clsx( + inputFieldsContainerStyle, + borderPlacement === 'container' && interactableBorderStyle({ isValid }), + )} > {childrenWithSeparators} </div> @@ -58,7 +55,7 @@ export const MultiInput = ({ isValid={isValid} /> </div> - </InputContext.Provider> + </InputProvider> ); }; @@ -80,4 +77,4 @@ const prepareSeparatorLayout = (separator: string | (JSX.Element & { props: Sepa MultiInput.InputField = InputField; -export default MultiInput; \ No newline at end of file +export default MultiInput; diff --git a/frontend/src/components/Input/SingleInput.tsx b/frontend/packages/ui/src/components/Input/SingleInput.tsx similarity index 74% rename from frontend/src/components/Input/SingleInput.tsx rename to frontend/packages/ui/src/components/Input/SingleInput.tsx index 4d3a5ce1..ed8d5ec6 100644 --- a/frontend/src/components/Input/SingleInput.tsx +++ b/frontend/packages/ui/src/components/Input/SingleInput.tsx @@ -1,38 +1,37 @@ +import { clsx } from '@endolphin/core/utils'; import type { InputHTMLAttributes } from 'react'; -import clsx from '@/utils/clsx'; - import { type CommonInputProps } from '.'; import HelperText from './Core/HelperText'; import InputField from './Core/InputField'; import Label from './Core/Label'; import { containerStyle, inputFieldsContainerStyle, interactableBorderStyle } from './index.css'; -import { InputContext } from './InputContext'; +import { InputProvider } from './InputContext'; export interface SingleInputProps extends CommonInputProps { inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, 'placeholder' | 'onClick' | 'readOnly'>; } -export const SingleInput = ({ +export const SingleInput = ({ label, type = 'text', isValid = true, - required = false, - hint, + required = false, + hint, error, placeholder, onClick, inputProps = {}, className, }: SingleInputProps) => ( - <InputContext.Provider value={{ isValid, type }}> + <InputProvider initialValue={{ isValid, type }}> <div className={clsx(className, containerStyle)}> {label && <Label required={required}>{label}</Label>} <div className={`${inputFieldsContainerStyle} ${interactableBorderStyle({ isValid })}`}> - <InputField + <InputField {...inputProps} - onClick={onClick} - placeholder={placeholder} + onClick={onClick} + placeholder={placeholder} /> </div> <HelperText @@ -41,6 +40,6 @@ export const SingleInput = ({ isValid={isValid} /> </div> - </InputContext.Provider> + </InputProvider> ); -export default SingleInput; \ No newline at end of file +export default SingleInput; diff --git a/frontend/src/components/Input/index.css.ts b/frontend/packages/ui/src/components/Input/index.css.ts similarity index 95% rename from frontend/src/components/Input/index.css.ts rename to frontend/packages/ui/src/components/Input/index.css.ts index 356b1953..09ce87c7 100644 --- a/frontend/src/components/Input/index.css.ts +++ b/frontend/packages/ui/src/components/Input/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '../../theme/index.css'; - export const containerStyle = style({ width: '100%', display: 'flex', diff --git a/frontend/src/components/Input/index.tsx b/frontend/packages/ui/src/components/Input/index.tsx similarity index 100% rename from frontend/src/components/Input/index.tsx rename to frontend/packages/ui/src/components/Input/index.tsx diff --git a/frontend/src/components/Pagination/Pagination.stories.tsx b/frontend/packages/ui/src/components/Pagination/Pagination.stories.tsx similarity index 100% rename from frontend/src/components/Pagination/Pagination.stories.tsx rename to frontend/packages/ui/src/components/Pagination/Pagination.stories.tsx diff --git a/frontend/src/components/Pagination/PaginationItem.tsx b/frontend/packages/ui/src/components/Pagination/PaginationItem.tsx similarity index 96% rename from frontend/src/components/Pagination/PaginationItem.tsx rename to frontend/packages/ui/src/components/Pagination/PaginationItem.tsx index 49de6fa9..15ca883e 100644 --- a/frontend/src/components/Pagination/PaginationItem.tsx +++ b/frontend/packages/ui/src/components/Pagination/PaginationItem.tsx @@ -1,5 +1,6 @@ -import { vars } from '../../theme/index.css'; +import { vars } from '@endolphin/theme'; + import { IconDotsMono } from '../Icon'; import { Text } from '../Text'; import { SEPARATOR } from '.'; diff --git a/frontend/src/components/Pagination/index.css.ts b/frontend/packages/ui/src/components/Pagination/index.css.ts similarity index 95% rename from frontend/src/components/Pagination/index.css.ts rename to frontend/packages/ui/src/components/Pagination/index.css.ts index 6d2b5d80..80f80cb2 100644 --- a/frontend/src/components/Pagination/index.css.ts +++ b/frontend/packages/ui/src/components/Pagination/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '../../theme/index.css'; - export const paginationContainerStyle = style({ display: 'flex', alignItems: 'center', diff --git a/frontend/src/components/Pagination/index.tsx b/frontend/packages/ui/src/components/Pagination/index.tsx similarity index 68% rename from frontend/src/components/Pagination/index.tsx rename to frontend/packages/ui/src/components/Pagination/index.tsx index d35337bb..9adb64da 100644 --- a/frontend/src/components/Pagination/index.tsx +++ b/frontend/packages/ui/src/components/Pagination/index.tsx @@ -1,8 +1,6 @@ -import clsx from '@/utils/clsx'; +import { clsx } from '@endolphin/core/utils'; -import { - paginationContainerStyle, -} from './index.css'; +import { paginationContainerStyle } from './index.css'; import PaginationItem from './PaginationItem'; const PAGE_LIMIT = 8; @@ -26,25 +24,23 @@ const Pagination = ({ const pages = getPaginationItems(currentPage, totalPages); return ( - // totalPages > 1 && - <div className={clsx(paginationContainerStyle, className)}> - {pages.map((item, index) => - <PaginationItem - currentPage={currentPage} - item={item} - key={index} - onHover={onPageButtonHover} - onPageChange={onPageChange} - />, - )} - </div> + totalPages > 1 && ( + <div className={clsx(paginationContainerStyle, className)}> + {pages.map((item, index) => ( + <PaginationItem + currentPage={currentPage} + item={item} + key={index} + onHover={onPageButtonHover} + onPageChange={onPageChange} + /> + ))} + </div> + ) ); }; -const getPaginationItems = ( - currentPage: number, - totalPages: number, -): (number | 'separator')[] => { +const getPaginationItems = (currentPage: number, totalPages: number): (number | 'separator')[] => { // 총 페이지가 페이지 제한보다 작을 경우 if (totalPages <= PAGE_LIMIT) { return Array.from({ length: totalPages }, (_, i) => i + 1); @@ -68,13 +64,7 @@ const getPaginationItems = ( { length: middlePageCount }, (_, i) => i + currentPage - Math.floor(middlePageCount / 2), ); - return [ - 1, - SEPARATOR, - ...middlePages, - SEPARATOR, - totalPages, - ]; + return [1, SEPARATOR, ...middlePages, SEPARATOR, totalPages]; }; export default Pagination; diff --git a/frontend/packages/ui/src/components/SegmentControl/Content.tsx b/frontend/packages/ui/src/components/SegmentControl/Content.tsx new file mode 100644 index 00000000..0d519759 --- /dev/null +++ b/frontend/packages/ui/src/components/SegmentControl/Content.tsx @@ -0,0 +1,18 @@ +import { clsx } from '@endolphin/core/utils'; +import type { PropsWithChildren } from 'react'; + +import { contentContainerStyle } from './index.css'; +import { useSegmentControlContext } from './SegmentControlContext'; + +export interface ContentProps extends PropsWithChildren { + value: string; + className?: string; +} + +const Content = ({ value, className, children }: ContentProps) => { + const { selectedValue } = useSegmentControlContext(); + if (selectedValue !== value) return null; + return <section className={clsx(contentContainerStyle, className)}>{children}</section>; +}; + +export default Content; diff --git a/frontend/src/components/SegmentControl/ControlButton.tsx b/frontend/packages/ui/src/components/SegmentControl/ControlButton.tsx similarity index 82% rename from frontend/src/components/SegmentControl/ControlButton.tsx rename to frontend/packages/ui/src/components/SegmentControl/ControlButton.tsx index 1e7a675f..8a4b036d 100644 --- a/frontend/src/components/SegmentControl/ControlButton.tsx +++ b/frontend/packages/ui/src/components/SegmentControl/ControlButton.tsx @@ -1,8 +1,6 @@ -import { useSafeContext } from '@/hooks/useSafeContext'; - import Button from '../Button'; import type { SegmentControlProps, SegmentOption } from '.'; -import { SegmentControlContext } from './SegmentControlContext'; +import { useSegmentControlContext } from './SegmentControlContext'; interface ControlButtonProps { segmentOption: SegmentOption; @@ -15,7 +13,7 @@ const ControlButton = ({ segmentControlStyle, onButtonHover, }: ControlButtonProps ) => { - const { selectedValue, handleSelect } = useSafeContext(SegmentControlContext); + const { selectedValue, handleSelect } = useSegmentControlContext(); const { label, value } = segmentOption; return ( <Button diff --git a/frontend/src/components/SegmentControl/SegmentControl.stories.tsx b/frontend/packages/ui/src/components/SegmentControl/SegmentControl.stories.tsx similarity index 100% rename from frontend/src/components/SegmentControl/SegmentControl.stories.tsx rename to frontend/packages/ui/src/components/SegmentControl/SegmentControl.stories.tsx diff --git a/frontend/packages/ui/src/components/SegmentControl/SegmentControlContext.ts b/frontend/packages/ui/src/components/SegmentControl/SegmentControlContext.ts new file mode 100644 index 00000000..e9dcea26 --- /dev/null +++ b/frontend/packages/ui/src/components/SegmentControl/SegmentControlContext.ts @@ -0,0 +1,11 @@ +import { createStateContext } from '@endolphin/core/utils'; + +interface SegmentControlContextProps { + selectedValue: string; + handleSelect: (value: string) => void; +} + +export const { + StateProvider: SegmentControlProvider, + useContextState: useSegmentControlContext, +} = createStateContext<SegmentControlContextProps, SegmentControlContextProps, object>(); \ No newline at end of file diff --git a/frontend/src/components/SegmentControl/index.css.ts b/frontend/packages/ui/src/components/SegmentControl/index.css.ts similarity index 93% rename from frontend/src/components/SegmentControl/index.css.ts rename to frontend/packages/ui/src/components/SegmentControl/index.css.ts index 7fc9b31f..fe635f3d 100644 --- a/frontend/src/components/SegmentControl/index.css.ts +++ b/frontend/packages/ui/src/components/SegmentControl/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '../../theme/index.css'; - export const controlButtonContainerStyle = recipe({ base: { display: 'inline-flex', diff --git a/frontend/src/components/SegmentControl/index.tsx b/frontend/packages/ui/src/components/SegmentControl/index.tsx similarity index 90% rename from frontend/src/components/SegmentControl/index.tsx rename to frontend/packages/ui/src/components/SegmentControl/index.tsx index 0cb4e6f0..d6ca8759 100644 --- a/frontend/src/components/SegmentControl/index.tsx +++ b/frontend/packages/ui/src/components/SegmentControl/index.tsx @@ -5,7 +5,7 @@ import { Flex } from '../Flex'; import Content from './Content'; import ControlButton from './ControlButton'; import { controlButtonContainerStyle } from './index.css'; -import { SegmentControlContext } from './SegmentControlContext'; +import { SegmentControlProvider } from './SegmentControlContext'; export interface SegmentOption { label: string; @@ -38,7 +38,7 @@ const SegmentControl = ({ }; return ( - <SegmentControlContext.Provider value={{ selectedValue, handleSelect }}> + <SegmentControlProvider initialValue={{ selectedValue, handleSelect }}> <Flex className={className} direction='column'> <Flex as='ul' @@ -56,7 +56,7 @@ const SegmentControl = ({ </Flex> {children} </Flex> - </SegmentControlContext.Provider> + </SegmentControlProvider> ); }; diff --git a/frontend/src/components/Tab/Tab.stories.tsx b/frontend/packages/ui/src/components/Tab/Tab.stories.tsx similarity index 100% rename from frontend/src/components/Tab/Tab.stories.tsx rename to frontend/packages/ui/src/components/Tab/Tab.stories.tsx diff --git a/frontend/src/components/Tab/TabContent.tsx b/frontend/packages/ui/src/components/Tab/TabContent.tsx similarity index 67% rename from frontend/src/components/Tab/TabContent.tsx rename to frontend/packages/ui/src/components/Tab/TabContent.tsx index ced3a3fe..41164339 100644 --- a/frontend/src/components/Tab/TabContent.tsx +++ b/frontend/packages/ui/src/components/Tab/TabContent.tsx @@ -1,19 +1,17 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; import { useId } from 'react'; -import { useSafeContext } from '@/hooks/useSafeContext'; -import clsx from '@/utils/clsx'; - import { tabContentStyle } from './index.css'; -import { TabContext } from './TabContext'; +import { useTabContext } from './TabContext'; -interface TabContentProps extends PropsWithChildren { +export interface TabContentProps extends PropsWithChildren { value: string; className?: string; } export const TabContent = ({ value, className, children }: TabContentProps) => { - const { controlId, selectedValue, onChange } = useSafeContext(TabContext); + const { controlId, selectedValue, onChange } = useTabContext(); const defaultId = `${controlId}-item-${useId()}`; const handleClick = () => { @@ -24,12 +22,12 @@ export const TabContent = ({ value, className, children }: TabContentProps) => { if (!isSelected) return null; return ( - <section + <section className={clsx(className, tabContentStyle)} id={defaultId} onClick={handleClick} > {children} </section> - ); -}; \ No newline at end of file + ); +}; diff --git a/frontend/src/components/Tab/TabContext.ts b/frontend/packages/ui/src/components/Tab/TabContext.ts similarity index 62% rename from frontend/src/components/Tab/TabContext.ts rename to frontend/packages/ui/src/components/Tab/TabContext.ts index 2d30c0c1..ad0e351b 100644 --- a/frontend/src/components/Tab/TabContext.ts +++ b/frontend/packages/ui/src/components/Tab/TabContext.ts @@ -1,4 +1,4 @@ -import { createContext } from 'react'; +import { createStateContext } from '@endolphin/core/utils'; export interface TabContextProps<T extends string = string> { controlId: string; @@ -13,4 +13,7 @@ export interface TabContextProps<T extends string = string> { * @param selectedValue - Tab 컴포넌트의 선택된 값. * @param onChange - Tab 컴포넌트의 값 변경 함수. */ -export const TabContext = createContext<TabContextProps<string> | null>(null); \ No newline at end of file +export const { + StateProvider: TabProvider, + useContextState: useTabContext, +} = createStateContext<TabContextProps<string>, TabContextProps<string>, object>(); \ No newline at end of file diff --git a/frontend/src/components/Tab/TabItem.tsx b/frontend/packages/ui/src/components/Tab/TabItem.tsx similarity index 72% rename from frontend/src/components/Tab/TabItem.tsx rename to frontend/packages/ui/src/components/Tab/TabItem.tsx index 889d943c..adb609c0 100644 --- a/frontend/src/components/Tab/TabItem.tsx +++ b/frontend/packages/ui/src/components/Tab/TabItem.tsx @@ -1,18 +1,16 @@ import type { PropsWithChildren } from 'react'; import { useId } from 'react'; -import { useSafeContext } from '@/hooks/useSafeContext'; - import { Text } from '../Text'; import { tabItemStyle } from './index.css'; -import { TabContext } from './TabContext'; +import { useTabContext } from './TabContext'; -interface TabItemProps extends PropsWithChildren { +export interface TabItemProps extends PropsWithChildren { value: string; } export const TabItem = ({ value, children }: TabItemProps) => { - const { controlId, selectedValue, onChange } = useSafeContext(TabContext); + const { controlId, selectedValue, onChange } = useTabContext(); const defaultId = `${controlId}-item-${useId()}`; const handleClick = () => { diff --git a/frontend/src/components/Tab/TabItemList.tsx b/frontend/packages/ui/src/components/Tab/TabItemList.tsx similarity index 52% rename from frontend/src/components/Tab/TabItemList.tsx rename to frontend/packages/ui/src/components/Tab/TabItemList.tsx index f9164073..2eee04c2 100644 --- a/frontend/src/components/Tab/TabItemList.tsx +++ b/frontend/packages/ui/src/components/Tab/TabItemList.tsx @@ -1,15 +1,12 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - import { tabListStyle } from './index.css'; -interface TabItemListProps extends PropsWithChildren { +export interface TabItemListProps extends PropsWithChildren { className?: string; } export const TabItemList = ({ children, className }: TabItemListProps) => ( - <ul className={clsx(className, tabListStyle)}> - {children} - </ul> -); \ No newline at end of file + <ul className={clsx(className, tabListStyle)}>{children}</ul> +); diff --git a/frontend/src/components/Tab/index.css.ts b/frontend/packages/ui/src/components/Tab/index.css.ts similarity index 96% rename from frontend/src/components/Tab/index.css.ts rename to frontend/packages/ui/src/components/Tab/index.css.ts index 6c229218..d910a165 100644 --- a/frontend/src/components/Tab/index.css.ts +++ b/frontend/packages/ui/src/components/Tab/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const tabContainerStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/frontend/src/components/Tab/index.tsx b/frontend/packages/ui/src/components/Tab/index.tsx similarity index 69% rename from frontend/src/components/Tab/index.tsx rename to frontend/packages/ui/src/components/Tab/index.tsx index 1ae4b6ac..75744c58 100644 --- a/frontend/src/components/Tab/index.tsx +++ b/frontend/packages/ui/src/components/Tab/index.tsx @@ -1,32 +1,31 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; import { useId } from 'react'; -import clsx from '@/utils/clsx'; - import { tabContainerStyle } from './index.css'; import { TabContent } from './TabContent'; -import { TabContext } from './TabContext'; +import { TabProvider } from './TabContext'; import { TabItem } from './TabItem'; import { TabItemList } from './TabItemList'; interface TabProps<T extends string = string> extends PropsWithChildren { - onChange: ((value: T) => void); + onChange: (value: T) => void; selectedValue: T; className?: string; } -export const Tab = <T extends string, _ = null>({ - onChange, +export const Tab = <T extends string, _ = null>({ + onChange, selectedValue, - children, + children, className, }: TabProps<T>) => { const defaultId = `Tab-${useId()}`; return ( - <TabContext.Provider - value={{ - controlId: defaultId, + <TabProvider + initialValue={{ + controlId: defaultId, selectedValue, onChange: onChange as (value: string) => void, }} @@ -34,10 +33,10 @@ export const Tab = <T extends string, _ = null>({ <div className={clsx(className, tabContainerStyle)} id={defaultId}> {children} </div> - </TabContext.Provider> + </TabProvider> ); }; Tab.List = TabItemList; Tab.Item = TabItem; -Tab.Content = TabContent; \ No newline at end of file +Tab.Content = TabContent; diff --git a/frontend/src/components/Text/index.tsx b/frontend/packages/ui/src/components/Text/index.tsx similarity index 80% rename from frontend/src/components/Text/index.tsx rename to frontend/packages/ui/src/components/Text/index.tsx index 18f41f89..2819c6a2 100644 --- a/frontend/src/components/Text/index.tsx +++ b/frontend/packages/ui/src/components/Text/index.tsx @@ -1,7 +1,6 @@ +import { clsx } from '@endolphin/core/utils'; import type { PropsWithChildren } from 'react'; -import clsx from '@/utils/clsx'; - import * as styles from './text.css'; export type Typo = keyof typeof styles; @@ -10,9 +9,10 @@ interface TextProps extends PropsWithChildren { typo?: Typo; color?: string; className?: string; -} +} -export const Text = ({ children, typo = 't2', color = 'current', className }: TextProps) => +export const Text = ({ children, typo = 't2', color = 'current', className }: TextProps) => ( <span className={clsx(styles[typo], className)} style={{ color }}> {children} - </span>; \ No newline at end of file + </span> +); diff --git a/frontend/src/components/Text/text.css.ts b/frontend/packages/ui/src/components/Text/text.css.ts similarity index 94% rename from frontend/src/components/Text/text.css.ts rename to frontend/packages/ui/src/components/Text/text.css.ts index 66f9f6e5..f83d46c1 100644 --- a/frontend/src/components/Text/text.css.ts +++ b/frontend/packages/ui/src/components/Text/text.css.ts @@ -1,7 +1,6 @@ +import { font } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; -import { font } from '@/theme/font'; - export const h1 = style({ ...font['H1'], }); diff --git a/frontend/src/components/Toggle/Toggle.stories.tsx b/frontend/packages/ui/src/components/Toggle/Toggle.stories.tsx similarity index 96% rename from frontend/src/components/Toggle/Toggle.stories.tsx rename to frontend/packages/ui/src/components/Toggle/Toggle.stories.tsx index f329ae46..a4320584 100644 --- a/frontend/src/components/Toggle/Toggle.stories.tsx +++ b/frontend/packages/ui/src/components/Toggle/Toggle.stories.tsx @@ -1,9 +1,8 @@ +import { useGroup } from '@hooks/useGroup'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { useGroup } from '@/hooks/useGroup'; - import { Group } from '../Group'; import { Toggle } from '.'; diff --git a/frontend/src/components/Toggle/index.css.ts b/frontend/packages/ui/src/components/Toggle/index.css.ts similarity index 95% rename from frontend/src/components/Toggle/index.css.ts rename to frontend/packages/ui/src/components/Toggle/index.css.ts index 1927f8bb..5183bb74 100644 --- a/frontend/src/components/Toggle/index.css.ts +++ b/frontend/packages/ui/src/components/Toggle/index.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '@/theme/index.css'; - export const containerStyle = recipe({ base: { width: 28, diff --git a/frontend/src/components/Toggle/index.tsx b/frontend/packages/ui/src/components/Toggle/index.tsx similarity index 97% rename from frontend/src/components/Toggle/index.tsx rename to frontend/packages/ui/src/components/Toggle/index.tsx index a490c62f..d0952d47 100644 --- a/frontend/src/components/Toggle/index.tsx +++ b/frontend/packages/ui/src/components/Toggle/index.tsx @@ -1,8 +1,7 @@ +import { useCheckbox } from '@hooks/useCheckbox'; import type { ChangeEvent, InputHTMLAttributes, SetStateAction } from 'react'; import { useId } from 'react'; -import { useCheckbox } from '@/hooks/useCheckbox'; - import { checkboxStyle, containerStyle, inputStyle } from './index.css'; interface ToggleProps { diff --git a/frontend/src/components/Tooltip/TooltipContent.tsx b/frontend/packages/ui/src/components/Tooltip/TooltipContent.tsx similarity index 100% rename from frontend/src/components/Tooltip/TooltipContent.tsx rename to frontend/packages/ui/src/components/Tooltip/TooltipContent.tsx diff --git a/frontend/src/components/Tooltip/index.css.ts b/frontend/packages/ui/src/components/Tooltip/index.css.ts similarity index 100% rename from frontend/src/components/Tooltip/index.css.ts rename to frontend/packages/ui/src/components/Tooltip/index.css.ts diff --git a/frontend/src/components/Tooltip/index.tsx b/frontend/packages/ui/src/components/Tooltip/index.tsx similarity index 96% rename from frontend/src/components/Tooltip/index.tsx rename to frontend/packages/ui/src/components/Tooltip/index.tsx index b25603f5..02571339 100644 --- a/frontend/src/components/Tooltip/index.tsx +++ b/frontend/packages/ui/src/components/Tooltip/index.tsx @@ -1,6 +1,6 @@ +import { vars } from '@endolphin/theme'; import type { PropsWithChildren, ReactNode } from 'react'; -import { vars } from '../../theme/index.css'; import { TooltipArrowDown, TooltipArrowLeft, diff --git a/frontend/src/components/Tooltip/tooltip.stories.tsx b/frontend/packages/ui/src/components/Tooltip/tooltip.stories.tsx similarity index 100% rename from frontend/src/components/Tooltip/tooltip.stories.tsx rename to frontend/packages/ui/src/components/Tooltip/tooltip.stories.tsx diff --git a/frontend/src/components/Tooltip/tooltipContent.css.ts b/frontend/packages/ui/src/components/Tooltip/tooltipContent.css.ts similarity index 94% rename from frontend/src/components/Tooltip/tooltipContent.css.ts rename to frontend/packages/ui/src/components/Tooltip/tooltipContent.css.ts index e2dbe549..01e457cc 100644 --- a/frontend/src/components/Tooltip/tooltipContent.css.ts +++ b/frontend/packages/ui/src/components/Tooltip/tooltipContent.css.ts @@ -1,8 +1,7 @@ +import { vars } from '@endolphin/theme'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { vars } from '../../theme/index.css'; - export const contentContainerStyle = recipe({ base: { borderRadius: 10, diff --git a/frontend/src/hooks/useCheckbox.ts b/frontend/packages/ui/src/hooks/useCheckbox.ts similarity index 88% rename from frontend/src/hooks/useCheckbox.ts rename to frontend/packages/ui/src/hooks/useCheckbox.ts index 6d18fe8f..433cf08f 100644 --- a/frontend/src/hooks/useCheckbox.ts +++ b/frontend/packages/ui/src/hooks/useCheckbox.ts @@ -1,8 +1,8 @@ import type { SetStateAction } from 'react'; -import { useContext, useState } from 'react'; +import { useState } from 'react'; -import { GroupContext } from '../components/Group/GroupContext'; +import { useUnsafeGroupContext } from '../components/Group/GroupContext'; interface CheckedStateProps { defaultChecked?: boolean; @@ -24,7 +24,7 @@ export const useCheckbox = ({ onToggleCheck, type, }: CheckedStateProps): UseCheckboxReturn => { - const group = useContext(GroupContext); + const group = useUnsafeGroupContext(); const [checked, setChecked] = useState<boolean>(defaultChecked || false); const handleClickCheck = () => { diff --git a/frontend/packages/ui/src/hooks/useGroup.ts b/frontend/packages/ui/src/hooks/useGroup.ts new file mode 100644 index 00000000..02fe0f6a --- /dev/null +++ b/frontend/packages/ui/src/hooks/useGroup.ts @@ -0,0 +1,92 @@ +import { useReducer } from 'react'; + +type State = { + checkedList: Set<number>; + isAllChecked: boolean; +}; + +type Action = + | { type: 'TOGGLE_ITEM'; id: number; itemIds: number[] } + | { type: 'TOGGLE_ALL'; itemIds: number[] } + | { type: 'RESET' } + | { type: 'INIT'; defaultCheckedList: number[]; itemIds: number[] }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'TOGGLE_ITEM': { + const newCheckedList = new Set(state.checkedList); + if (newCheckedList.has(action.id)) { + newCheckedList.delete(action.id); + } else { + newCheckedList.add(action.id); + } + const isAllChecked = newCheckedList.size === action.itemIds.length; + return { checkedList: newCheckedList, isAllChecked }; + } + + case 'TOGGLE_ALL': { + if (state.isAllChecked) { + return { isAllChecked: false, checkedList: new Set() }; + } + return { isAllChecked: true, checkedList: new Set(action.itemIds) }; + } + + case 'RESET': { + return { checkedList: new Set(), isAllChecked: false }; + } + + case 'INIT': { + return initializeState(action.defaultCheckedList, action.itemIds); + } + + default: + return state; + } +}; + +const initializeState = (defaultCheckedList: number[], itemIds: number[]): State => ({ + checkedList: new Set(defaultCheckedList), + isAllChecked: defaultCheckedList.length === itemIds.length, +}); + +interface GroupStateProps { + defaultCheckedList?: number[]; + itemIds: number[]; +} + +export interface GroupStateReturn { + checkedList: Set<number>; + handleToggleCheck: (id: number) => void; + isAllChecked: boolean; + handleToggleAllCheck: () => void; + reset: () => void; + init: () => void; +} + +export const useGroup = ({ + defaultCheckedList = [], + itemIds, +}: GroupStateProps): GroupStateReturn => { + const [state, dispatch] = useReducer(reducer, initializeState(defaultCheckedList, itemIds)); + + const handleToggleCheck = (id: number) => { + dispatch({ type: 'TOGGLE_ITEM', id, itemIds }); + }; + + const handleToggleAllCheck = () => { + dispatch({ type: 'TOGGLE_ALL', itemIds }); + }; + + const reset = () => { + dispatch({ type: 'RESET' }); + }; + + return { + checkedList: state.checkedList, + handleToggleCheck, + isAllChecked: state.isAllChecked, + handleToggleAllCheck, + reset, + init: () => dispatch({ type: 'INIT', defaultCheckedList, itemIds }), + }; +}; \ No newline at end of file diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts new file mode 100644 index 00000000..d27965be --- /dev/null +++ b/frontend/packages/ui/src/index.ts @@ -0,0 +1,20 @@ +export { default as Avatar } from './components/Avatar'; +export { Badge } from './components/Badge'; +export { default as Button } from './components/Button'; +export { Checkbox } from './components/Checkbox'; +export { Chip } from './components/Chip'; +export { Divider } from './components/Divider'; +export { Dropdown } from './components/Dropdown'; +export { Flex } from './components/Flex'; +export { Group } from './components/Group'; +export * as Icon from './components/Icon'; +export { Image } from './components/Image'; +export { default as Input } from './components/Input'; +export { default as Pagination } from './components/Pagination'; +export { default as SegmentControl } from './components/SegmentControl'; +export { Tab } from './components/Tab'; +export { Text } from './components/Text'; +export { Toggle } from './components/Toggle'; +export { default as Tooltip } from './components/Tooltip'; +export { useCheckbox } from './hooks/useCheckbox'; +export { useGroupContext, useUnsafeGroupContext } from '@components/Group/GroupContext'; \ No newline at end of file diff --git a/frontend/packages/ui/tsconfig.json b/frontend/packages/ui/tsconfig.json new file mode 100644 index 00000000..29a2c233 --- /dev/null +++ b/frontend/packages/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "types": ["vitest/globals"], + "paths": { + "@/*": ["src/*"], + "@constants/*": ["src/constants/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@components/*": ["src/components/*"], + }, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + + /* Module Resolution */ + "composite": false, + "outDir": "./dist", + }, + "include": ["src", "bundle.js"], +} \ No newline at end of file diff --git a/frontend/packages/ui/tsup.config.ts b/frontend/packages/ui/tsup.config.ts new file mode 100644 index 00000000..e700a7df --- /dev/null +++ b/frontend/packages/ui/tsup.config.ts @@ -0,0 +1,10 @@ +import { reactConfig } from '@endolphin/tsup-config'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + ...reactConfig, + external: ['@endolphin/theme'], + banner: { + js: 'import \'./index.css\';', + }, +}); diff --git a/frontend/packages/ui/vitest.config.ts b/frontend/packages/ui/vitest.config.ts new file mode 100644 index 00000000..faae7211 --- /dev/null +++ b/frontend/packages/ui/vitest.config.ts @@ -0,0 +1,17 @@ +import { createAlias, reactConfig } from '@endolphin/vitest-config'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineProject, mergeConfig } from 'vitest/config'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default mergeConfig( + reactConfig, + defineProject({ + root: dirname, + resolve: { + alias: createAlias(dirname), + }, + }), +); \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1fbae725..8f69cc47 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,41 +4,19 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild: 0.24.2 + importers: .: - dependencies: - '@tanstack/react-query': - specifier: ^5.66.0 - version: 5.66.0(react@19.0.0) - '@tanstack/react-router': - specifier: ^1.109.2 - version: 1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@vanilla-extract/css': - specifier: ^1.17.0 - version: 1.17.1 - '@vanilla-extract/dynamic': - specifier: ^2.1.2 - version: 2.1.2 - '@vanilla-extract/recipes': - specifier: ^0.5.5 - version: 0.5.5(@vanilla-extract/css@1.17.1) - express: - specifier: ^4.21.2 - version: 4.21.2 - jotai: - specifier: ^2.12.1 - version: 2.12.1(@types/react@19.0.8)(react@19.0.0) - react: - specifier: ^19.0.0 - version: 19.0.0 - react-dom: - specifier: ^19.0.0 - version: 19.0.0(react@19.0.0) - zod: - specifier: ^3.24.1 - version: 3.24.1 devDependencies: + '@changesets/changelog-git': + specifier: ^0.2.1 + version: 0.2.1 + '@changesets/cli': + specifier: ^2.29.4 + version: 2.29.4 '@chromatic-com/storybook': specifier: ^3.2.4 version: 3.2.4(react@19.0.0)(storybook@8.5.2(prettier@3.4.2)) @@ -59,7 +37,7 @@ importers: version: 8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3) '@storybook/react-vite': specifier: ^8.5.2 - version: 8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(rollup@4.32.0)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) + version: 8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(rollup@4.40.2)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) '@storybook/test': specifier: ^8.5.2 version: 8.5.2(storybook@8.5.2(prettier@3.4.2)) @@ -74,25 +52,46 @@ importers: version: 1.99.0(@tanstack/react-router@1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(csstype@3.1.3)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tanstack/router-plugin': specifier: ^1.99.3 - version: 1.99.3(@tanstack/react-router@1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) + version: 1.99.3(@tanstack/react-router@1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 '@testing-library/react': - specifier: ^16.2.0 - version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/react': specifier: ^19.0.8 version: 19.0.8 '@types/react-dom': specifier: ^19.0.3 version: 19.0.3(@types/react@19.0.8) + '@vanilla-extract/css': + specifier: ^1.17.0 + version: 1.17.2 + '@vanilla-extract/dynamic': + specifier: ^2.1.2 + version: 2.1.2 + '@vanilla-extract/esbuild-plugin': + specifier: 2.3.14 + version: 2.3.14(esbuild@0.24.2) + '@vanilla-extract/recipes': + specifier: ^0.5.5 + version: 0.5.5(@vanilla-extract/css@1.17.2) '@vanilla-extract/vite-plugin': specifier: ^4.0.19 - version: 4.0.20(@types/node@22.12.0)(tsx@4.19.2)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) + version: 4.0.20(@types/node@22.15.21)(tsx@4.19.2)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) + version: 4.3.4(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) + '@vitest/browser': + specifier: ^3.2.3 + version: 3.2.3(playwright@1.52.0)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))(vitest@3.2.3) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9.17.0 version: 9.19.0 @@ -120,12 +119,27 @@ importers: jsdom: specifier: ^26.0.0 version: 26.0.0 + playwright: + specifier: ^1.52.0 + version: 1.52.0 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) sharp: specifier: ^0.33.5 version: 0.33.5 storybook: specifier: ^8.5.2 version: 8.5.2(prettier@3.4.2) + tsc-alias: + specifier: ^1.8.15 + version: 1.8.15 + tsup: + specifier: ^8.4.0 + version: 8.4.0(postcss@8.5.1)(tsx@4.19.2)(typescript@5.6.3) typescript: specifier: ~5.6.2 version: 5.6.3 @@ -134,10 +148,163 @@ importers: version: 8.21.0(eslint@9.19.0)(typescript@5.6.3) vite: specifier: ^6.0.5 - version: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + version: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) vitest: - specifier: ^3.0.4 - version: 3.0.4(@types/node@22.12.0)(jsdom@26.0.0)(tsx@4.19.2) + specifier: ^3.2.3 + version: 3.2.3(@types/node@22.15.21)(@vitest/browser@3.2.3)(jsdom@26.0.0)(tsx@4.19.2) + + apps/client: + dependencies: + '@endolphin/calendar': + specifier: workspace:* + version: link:../../packages/calendar + '@endolphin/core': + specifier: workspace:* + version: link:../../packages/core + '@endolphin/date-time': + specifier: workspace:* + version: link:../../packages/date-time + '@endolphin/theme': + specifier: workspace:* + version: link:../../packages/theme + '@endolphin/ui': + specifier: workspace:* + version: link:../../packages/ui + '@tanstack/react-query': + specifier: ^5.66.0 + version: 5.66.0(react@19.0.0) + '@tanstack/react-router': + specifier: ^1.109.2 + version: 1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + jotai: + specifier: ^2.12.1 + version: 2.12.1(@types/react@19.0.8)(react@19.0.0) + zod: + specifier: ^3.24.1 + version: 3.24.1 + devDependencies: + '@endolphin/vitest-config': + specifier: workspace:* + version: link:../../configs/vitest-config + + apps/server: + dependencies: + express: + specifier: ^5.1.0 + version: 5.1.0 + devDependencies: + '@endolphin/tsup-config': + specifier: workspace:^ + version: link:../../configs/tsup-config + '@endolphin/vitest-config': + specifier: workspace:^ + version: link:../../configs/vitest-config + '@types/express': + specifier: ^5.0.2 + version: 5.0.2 + typescript: + specifier: ^5.6.2 + version: 5.6.3 + + configs/tsup-config: {} + + configs/vitest-config: {} + + packages/calendar: + dependencies: + '@endolphin/core': + specifier: workspace:^ + version: link:../core + '@endolphin/theme': + specifier: workspace:^ + version: link:../theme + '@endolphin/ui': + specifier: workspace:^ + version: link:../ui + '@vanilla-extract/css': + specifier: ^1.17.0 + version: 1.17.2 + '@vanilla-extract/dynamic': + specifier: ^2.1.2 + version: 2.1.2 + '@vanilla-extract/recipes': + specifier: ^0.5.5 + version: 0.5.5(@vanilla-extract/css@1.17.2) + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@endolphin/tsup-config': + specifier: workspace:^ + version: link:../../configs/tsup-config + '@endolphin/vitest-config': + specifier: workspace:^ + version: link:../../configs/vitest-config + + packages/core: + devDependencies: + '@endolphin/vitest-config': + specifier: workspace:^ + version: link:../../configs/vitest-config + + packages/date-time: + devDependencies: + '@endolphin/tsup-config': + specifier: workspace:^ + version: link:../../configs/tsup-config + '@endolphin/vitest-config': + specifier: workspace:^ + version: link:../../configs/vitest-config + + packages/theme: + dependencies: + '@endolphin/core': + specifier: workspace:^ + version: link:../core + '@vanilla-extract/css': + specifier: ^1.17.0 + version: 1.17.1 + '@vanilla-extract/recipes': + specifier: ^0.5.5 + version: 0.5.5(@vanilla-extract/css@1.17.1) + devDependencies: + '@endolphin/tsup-config': + specifier: workspace:^ + version: link:../../configs/tsup-config + + packages/ui: + dependencies: + '@endolphin/core': + specifier: workspace:^ + version: link:../core + '@endolphin/theme': + specifier: workspace:^ + version: link:../theme + '@vanilla-extract/css': + specifier: ^1.17.0 + version: 1.17.1 + '@vanilla-extract/dynamic': + specifier: ^2.1.2 + version: 2.1.2 + '@vanilla-extract/recipes': + specifier: ^0.5.5 + version: 0.5.5(@vanilla-extract/css@1.17.1) + react: + specifier: '>=19.0.0' + version: 19.0.0 + react-dom: + specifier: '>=19.0.0' + version: 19.0.0(react@19.0.0) + devDependencies: + '@endolphin/tsup-config': + specifier: workspace:^ + version: link:../../configs/tsup-config + '@endolphin/vitest-config': + specifier: workspace:^ + version: link:../../configs/vitest-config packages: @@ -246,6 +413,61 @@ packages: resolution: {integrity: sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==} engines: {node: '>=6.9.0'} + '@changesets/apply-release-plan@7.0.12': + resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} + + '@changesets/assemble-release-plan@6.0.8': + resolution: {integrity: sha512-y8+8LvZCkKJdbUlpXFuqcavpzJR80PN0OIfn8HZdwK7Sh6MgLXm4hKY5vu6/NDoKp8lAlM4ERZCqRMLxP4m+MQ==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.4': + resolution: {integrity: sha512-VW30x9oiFp/un/80+5jLeWgEU6Btj8IqOgI+X/zAYu4usVOWXjPIK5jSSlt5jsCU7/6Z7AxEkarxBxGUqkAmNg==} + hasBin: true + + '@changesets/config@3.1.1': + resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.12': + resolution: {integrity: sha512-KukdEgaafnyGryUwpHG2kZ7xJquOmWWWk5mmoeQaSvZTWH1DC5D/Sw6ClgGFYtQnOMSQhgoEbDxAbpIIayKH1g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.1': + resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.5': + resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chromatic-com/storybook@3.2.4': resolution: {integrity: sha512-5/bOOYxfwZ2BktXeqcCpOVAoR6UCoeART5t9FVy22hoo8F291zOuX4y3SDgm10B1GVU/ZTtJWPT2X9wZFlxYLg==} engines: {node: '>=16.0.0', yarn: '>=1.22.18'} @@ -286,204 +508,102 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@esbuild/aix-ppc64@0.23.1': - resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.23.1': - resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.23.1': - resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.23.1': - resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.23.1': - resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.23.1': - resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.23.1': - resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.23.1': - resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.23.1': - resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.23.1': - resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.23.1': - resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.23.1': - resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.23.1': - resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.23.1': - resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.23.1': - resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.23.1': - resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.23.1': - resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -496,84 +616,42 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.23.1': - resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.23.1': - resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.24.2': resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.23.1': - resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.23.1': - resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.23.1': - resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.23.1': - resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.23.1': - resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -739,6 +817,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2': resolution: {integrity: sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ==} peerDependencies: @@ -766,6 +848,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mdx-js/react@3.1.0': resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} peerDependencies: @@ -784,6 +872,13 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rollup/pluginutils@5.1.4': resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} engines: {node: '>=14.0.0'} @@ -798,96 +893,196 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.40.2': + resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.32.0': resolution: {integrity: sha512-qhFwQ+ljoymC+j5lXRv8DlaJYY/+8vyvYmVx074zrLsu5ZGWYsJNLjPPVJJjhZQpyAKUGPydOq9hRLLNvh1s3A==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.40.2': + resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.32.0': resolution: {integrity: sha512-44n/X3lAlWsEY6vF8CzgCx+LQaoqWGN7TzUfbJDiTIOjJm4+L2Yq+r5a8ytQRGyPqgJDs3Rgyo8eVL7n9iW6AQ==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.40.2': + resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.32.0': resolution: {integrity: sha512-F9ct0+ZX5Np6+ZDztxiGCIvlCaW87HBdHcozUfsHnj1WCUTBUubAoanhHUfnUHZABlElyRikI0mgcw/qdEm2VQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.40.2': + resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.32.0': resolution: {integrity: sha512-JpsGxLBB2EFXBsTLHfkZDsXSpSmKD3VxXCgBQtlPcuAqB8TlqtLcbeMhxXQkCDv1avgwNjF8uEIbq5p+Cee0PA==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.40.2': + resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.32.0': resolution: {integrity: sha512-wegiyBT6rawdpvnD9lmbOpx5Sph+yVZKHbhnSP9MqUEDX08G4UzMU+D87jrazGE7lRSyTRs6NEYHtzfkJ3FjjQ==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.40.2': + resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.32.0': resolution: {integrity: sha512-3pA7xecItbgOs1A5H58dDvOUEboG5UfpTq3WzAdF54acBbUM+olDJAPkgj1GRJ4ZqE12DZ9/hNS2QZk166v92A==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': + resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.32.0': resolution: {integrity: sha512-Y7XUZEVISGyge51QbYyYAEHwpGgmRrAxQXO3siyYo2kmaj72USSG8LtlQQgAtlGfxYiOwu+2BdbPjzEpcOpRmQ==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.40.2': + resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.32.0': resolution: {integrity: sha512-r7/OTF5MqeBrZo5omPXcTnjvv1GsrdH8a8RerARvDFiDwFpDVDnJyByYM/nX+mvks8XXsgPUxkwe/ltaX2VH7w==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.40.2': + resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.32.0': resolution: {integrity: sha512-HJbifC9vex9NqnlodV2BHVFNuzKL5OnsV2dvTw6e1dpZKkNjPG6WUq+nhEYV6Hv2Bv++BXkwcyoGlXnPrjAKXw==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.40.2': + resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.32.0': resolution: {integrity: sha512-VAEzZTD63YglFlWwRj3taofmkV1V3xhebDXffon7msNz4b14xKsz7utO6F8F4cqt8K/ktTl9rm88yryvDpsfOw==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': + resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.32.0': resolution: {integrity: sha512-Sts5DST1jXAc9YH/iik1C9QRsLcCoOScf3dfbY5i4kH9RJpKxiTBXqm7qU5O6zTXBTEZry69bGszr3SMgYmMcQ==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': + resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.32.0': resolution: {integrity: sha512-qhlXeV9AqxIyY9/R1h1hBD6eMvQCO34ZmdYvry/K+/MBs6d1nRFLm6BOiITLVI+nFAAB9kUB6sdJRKyVHXnqZw==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.40.2': + resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.40.2': + resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.32.0': resolution: {integrity: sha512-8ZGN7ExnV0qjXa155Rsfi6H8M4iBBwNLBM9lcVS+4NcSzOFaNqmt7djlox8pN1lWrRPMRRQ8NeDlozIGx3Omsw==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.40.2': + resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.32.0': resolution: {integrity: sha512-VDzNHtLLI5s7xd/VubyS10mq6TxvZBp+4NRWoW+Hi3tgV05RtVm4qK99+dClwTN1McA6PHwob6DEJ6PlXbY83A==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.40.2': + resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.32.0': resolution: {integrity: sha512-qcb9qYDlkxz9DxJo7SDhWxTWV1gFuwznjbTiov289pASxlfGbaOD54mgbs9+z94VwrXtKTu+2RqwlSTbiOqxGg==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.40.2': + resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.32.0': resolution: {integrity: sha512-pFDdotFDMXW2AXVbfdUEfidPAk/OtwE/Hd4eYMTNVVaCQ6Yl8et0meDaKNL63L44Haxv4UExpv9ydSf3aSayDg==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.40.2': + resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.32.0': resolution: {integrity: sha512-/TG7WfrCAjeRNDvI4+0AAMoHxea/USWhAzf9PVDFHbcqrQ7hMMKp4jZIy4VEjk72AAfN5k4TiSMRXRKf/0akSw==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.40.2': + resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.32.0': resolution: {integrity: sha512-5hqO5S3PTEO2E5VjCePxv40gIgyS2KvO7E7/vvC/NbIW4SIRamkMr1hqj+5Y67fbBWv/bQLB6KelBQmXlyCjWA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.40.2': + resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1149,8 +1344,12 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@16.2.0': - resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==} + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 @@ -1170,6 +1369,12 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1185,12 +1390,36 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/express-serve-static-core@5.0.6': + resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} + + '@types/express@5.0.2': + resolution: {integrity: sha512-BtjL3ZwbCQriyb0DGw+Rt12qAXPiBTPs815lsUvtt1Grk0vLRMZNMUZ741d5rjk+UQOxfDiBZ3dxpX00vSkK3g==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1200,9 +1429,24 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@22.12.0': resolution: {integrity: sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==} + '@types/node@22.15.21': + resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.0.3': resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==} peerDependencies: @@ -1214,6 +1458,12 @@ packages: '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -1267,21 +1517,47 @@ packages: '@vanilla-extract/babel-plugin-debug-ids@1.2.0': resolution: {integrity: sha512-z5nx2QBnOhvmlmBKeRX5sPVLz437wV30u+GJL+Hzj1rGiJYVNvgIIlzUpRNjVQ0MgAgiQIqIUbqPnmMc6HmDlQ==} - '@vanilla-extract/compiler@0.1.1': - resolution: {integrity: sha512-OJk31hrDZlDKP7K3Yr5y731Wrm1vf7fNyM5eXjfGyDovZLcVAFuB8tt7pXqpdCz0RnrKsMzlvJD3f6WT2MaIug==} + '@vanilla-extract/babel-plugin-debug-ids@1.2.1': + resolution: {integrity: sha512-RkXKzcKVZtcDNmcGh8Bv9MNW6oYYUzy90GYt8amMrk5P+myXsdFSU9N7V+cJAf80l+AsMVMyK0GK7Qj35Sfppg==} + + '@vanilla-extract/compiler@0.1.3': + resolution: {integrity: sha512-dSkRFwHfOccEZGlQ6hdRDGQMLko8RZnAKd06u9+gPkRyjNt96nG6ZE/wEh4+3cdY27DPdTLh+TPlTp2DYo94OA==} '@vanilla-extract/css@1.17.1': resolution: {integrity: sha512-tOHQXHm10FrJeXKFeWE09JfDGN/tvV6mbjwoNB9k03u930Vg021vTnbrCwVLkECj9Zvh/SHLBHJ4r2flGqfovw==} + '@vanilla-extract/css@1.17.2': + resolution: {integrity: sha512-gowpfR1zJSplDO7NkGf2Vnw9v9eG1P3aUlQpxa1pOjcknbgWw7UPzIboB6vGJZmoUvDZRFmipss3/Q+RRfhloQ==} + + '@vanilla-extract/css@1.17.3': + resolution: {integrity: sha512-jHivr1UPoJTX5Uel4AZSOwrCf4mO42LcdmnhJtUxZaRWhW4FviFbIfs0moAWWld7GOT+2XnuVZjjA/K32uUnMQ==} + '@vanilla-extract/dynamic@2.1.2': resolution: {integrity: sha512-9BGMciD8rO1hdSPIAh1ntsG4LPD3IYKhywR7VOmmz9OO4Lx1hlwkSg3E6X07ujFx7YuBfx0GDQnApG9ESHvB2A==} - '@vanilla-extract/integration@8.0.0': - resolution: {integrity: sha512-hsu5Cqs30RDTRgaOCtvdZttKIaMImObcZmylxPp693mr1pyRk+WEIEAzQAJkXE/JQ47xyqf+a63UxdI0Sf3PZw==} + '@vanilla-extract/esbuild-plugin@2.3.14': + resolution: {integrity: sha512-F3FjcJyZiceFlv1L9nPfUH3UHuXRBhWmCqF2syoVCwr0bsiwKxewnC4bznMDLDoVPsBlL5VLxYGzL4D7W3hsxw==} + peerDependencies: + esbuild: 0.24.2 + peerDependenciesMeta: + esbuild: + optional: true + + '@vanilla-extract/integration@8.0.2': + resolution: {integrity: sha512-w9OvWwsYkqyuyHf9NLnOJ8ap0FGTy2pAeWftgxAEkKE3tF1aYeyEtYRHKxfVH6JRgi8JIeQqELHGMSwz+BxwiA==} + + '@vanilla-extract/integration@8.0.3': + resolution: {integrity: sha512-7sCd4kBp/u02iNq3cYBXtOzKyX5muoHylsqcEjyuJWfnxOb1iB/jDCyTsiSRXcByj/wKjgjItbAdm/uzrUlAVw==} '@vanilla-extract/private@1.0.6': resolution: {integrity: sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==} + '@vanilla-extract/private@1.0.7': + resolution: {integrity: sha512-v9Yb0bZ5H5Kr8ciwPXyEToOFD7J/fKKH93BYP7NCSZg02VYsA/pNFrLeVDJM2OO/vsygduPKuiEI6ORGQ4IcBw==} + + '@vanilla-extract/private@1.0.8': + resolution: {integrity: sha512-oRAbUlq1SyTWCo7dQnTVm+xgJMqNl8K1dEempQHXzQvUuyEfBabMt0wNGf+VCHzvKbx/Bzr9p/2wy8WA9+2z2g==} + '@vanilla-extract/recipes@0.5.5': resolution: {integrity: sha512-VadU7+IFUwLNLMgks29AHav/K5h7DOEfTU91RItn5vwdPfzduodNg317YbgWCcpm7FSXkuR3B3X8ZOi95UOozA==} peerDependencies: @@ -1298,17 +1574,32 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + '@vitest/browser@3.2.3': + resolution: {integrity: sha512-5HpUb0ixGF8JWSAjb/P1x/VPuTYUkL4pL0+YO6DJiuvQgqJN3PREaUEcXwfXjU4nBc37EahfpRbAwdE9pHs9lQ==} + peerDependencies: + playwright: '*' + safaridriver: '*' + vitest: 3.2.3 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} - '@vitest/expect@3.0.4': - resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==} + '@vitest/expect@3.2.3': + resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==} - '@vitest/mocker@3.0.4': - resolution: {integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==} + '@vitest/mocker@3.2.3': + resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -1321,20 +1612,20 @@ packages: '@vitest/pretty-format@2.1.8': resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==} - '@vitest/pretty-format@3.0.4': - resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==} + '@vitest/pretty-format@3.2.3': + resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==} - '@vitest/runner@3.0.4': - resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==} + '@vitest/runner@3.2.3': + resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==} - '@vitest/snapshot@3.0.4': - resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==} + '@vitest/snapshot@3.2.3': + resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==} '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} - '@vitest/spy@3.0.4': - resolution: {integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==} + '@vitest/spy@3.2.3': + resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==} '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} @@ -1342,11 +1633,11 @@ packages: '@vitest/utils@2.1.8': resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} - '@vitest/utils@3.0.4': - resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} + '@vitest/utils@3.2.3': + resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} acorn-jsx@5.3.2: @@ -1366,6 +1657,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1382,14 +1677,24 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + ansis@3.10.0: resolution: {integrity: sha512-hxDKLYT7hy3Y4sF3HxI926A3urzPxi73mZBB629m9bCVF+NyKNxbwCqqm+C/YrGPtxLwnl6d8/ZASCsz6SyvJA==} engines: {node: '>=16'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1404,13 +1709,14 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -1464,13 +1770,17 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1490,6 +1800,12 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: 0.24.2 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1521,6 +1837,10 @@ packages: resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} engines: {node: '>=12'} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -1529,6 +1849,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1537,6 +1860,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chromatic@11.25.2: resolution: {integrity: sha512-/9eQWn6BU1iFsop86t8Au21IksTRxwXAl7if8YHD05L2AbuMjClLWZo5cZojqrJHGKDhTqfrC2X2xE4uSm0iKw==} hasBin: true @@ -1549,6 +1876,10 @@ packages: '@chromatic-com/playwright': optional: true + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1571,14 +1902,26 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} content-type@1.0.5: @@ -1588,13 +1931,19 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1634,24 +1983,25 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1708,9 +2058,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} @@ -1720,6 +2070,10 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1738,20 +2092,29 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.5.88: resolution: {integrity: sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1772,8 +2135,8 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1793,12 +2156,7 @@ packages: esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: - esbuild: '>=0.12 <1' - - esbuild@0.23.1: - resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} - engines: {node: '>=18'} - hasBin: true + esbuild: 0.24.2 esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} @@ -1939,13 +2297,20 @@ packages: resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} engines: {node: '>= 0.8'} - expect-type@1.1.0: - resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1963,6 +2328,14 @@ packages: fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1975,10 +2348,14 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1994,6 +2371,10 @@ packages: resolution: {integrity: sha512-kKaIINnFpzW6ffJNDjjyjrk21BkDx38c0xa/klsT8VzLCaMEefv4ZTacrcVR4DmgTeBra++jMDAfS/tS799YDw==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.1: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} @@ -2002,9 +2383,22 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -2048,6 +2442,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -2064,6 +2462,10 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + goober@2.1.16: resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} peerDependencies: @@ -2122,6 +2524,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-id@4.1.1: + resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} + hasBin: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2213,6 +2619,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -2236,6 +2646,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2252,6 +2665,10 @@ packages: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + is-symbol@1.1.1: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} @@ -2272,6 +2689,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -2286,6 +2707,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + javascript-stringify@2.1.0: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} @@ -2301,9 +2725,20 @@ packages: react: optional: true + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2344,6 +2779,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -2358,6 +2796,21 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2365,6 +2818,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2402,24 +2861,21 @@ packages: media-query-parser@2.0.2: resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2428,14 +2884,17 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} @@ -2451,18 +2910,34 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} modern-ahocorasick@1.1.0: resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mylas@2.1.13: + resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} + engines: {node: '>=12.0.0'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2471,8 +2946,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} node-releases@2.0.19: @@ -2521,6 +2996,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -2529,18 +3007,51 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2563,12 +3074,24 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} pathe@2.0.2: resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} @@ -2584,9 +3107,31 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -2595,6 +3140,24 @@ packages: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss@8.5.1: resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} @@ -2603,6 +3166,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.4.2: resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} engines: {node: '>=14'} @@ -2627,10 +3195,17 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2638,8 +3213,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} react-confetti@6.2.2: @@ -2685,10 +3260,18 @@ packages: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} engines: {node: '>= 4'} @@ -2715,6 +3298,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2736,6 +3323,15 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.40.2: + resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -2779,13 +3375,13 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -2833,9 +3429,21 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@3.0.1: + resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} + engines: {node: '>=18'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2844,6 +3452,16 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2851,8 +3469,8 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - std-env@3.8.0: - resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} storybook@8.5.2: resolution: {integrity: sha512-pf84emQ7Pd5jBdT2gzlNs4kRaSI3pq0Lh8lSfV+YqIVXztXIHU+Lqyhek2Lhjb7btzA1tExrhJrgQUsIji7i7A==} @@ -2863,6 +3481,14 @@ packages: prettier: optional: true + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -2882,6 +3508,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.0: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} @@ -2902,6 +3532,14 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2913,6 +3551,17 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2925,8 +3574,16 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.0: + resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: @@ -2941,6 +3598,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.76: resolution: {integrity: sha512-uzhJ02RaMzgQR3yPoeE65DrcHI6LoM4saUqXOt/b5hmb3+mc4YWpdSeAQqVqRUlQ14q8ZuLRWyBR1ictK1dzzg==} @@ -2948,6 +3609,10 @@ packages: resolution: {integrity: sha512-6U2ti64/nppsDxQs9hw8ephA3nO6nSQvVVfxwRw8wLQPFtLI1cFI1a1eP22g+LUP+1TA2pKKjUTwWB+K2coqmQ==} hasBin: true + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2956,14 +3621,25 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.0: resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} engines: {node: '>=16'} + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@5.0.0: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + ts-api-utils@2.0.0: resolution: {integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==} engines: {node: '>=18.12'} @@ -2974,6 +3650,14 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsc-alias@1.8.15: + resolution: {integrity: sha512-yKLVx8ddUurRwhVcS6JFF2ZjksOX2ZWDRIdgt+PQhJBDegIdAdilptiHsuAbx9UFxa16GFrxeKQ2kTcGvR6fkQ==} + engines: {node: '>=16.20.2'} + hasBin: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -2984,6 +3668,25 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.4.0: + resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + tsx@4.19.2: resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} engines: {node: '>=18.0.0'} @@ -3000,8 +3703,8 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} typed-array-buffer@1.0.3: @@ -3042,6 +3745,13 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3075,10 +3785,6 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -3087,8 +3793,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@3.0.4: - resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} + vite-node@3.2.3: + resolution: {integrity: sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -3132,16 +3838,16 @@ packages: yaml: optional: true - vitest@3.0.4: - resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} + vitest@3.2.3: + resolution: {integrity: sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.4 - '@vitest/ui': 3.0.4 + '@vitest/browser': 3.2.3 + '@vitest/ui': 3.2.3 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -3164,6 +3870,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3183,6 +3892,9 @@ packages: resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} engines: {node: '>=18'} + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3213,6 +3925,17 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -3225,6 +3948,18 @@ packages: utf-8-validate: optional: true + ws@8.18.2: + resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3341,47 +4076,189 @@ snapshots: '@babel/core': 7.26.7 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.7)': + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.7)': + dependencies: + '@babel/core': 7.26.7 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.7)': + dependencies: + '@babel/core': 7.26.7 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.7)': + dependencies: + '@babel/core': 7.26.7 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.26.7': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 + + '@babel/traverse@7.26.7': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.7 + '@babel/template': 7.25.9 + '@babel/types': 7.26.7 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.26.7': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@changesets/apply-release-plan@7.0.12': + dependencies: + '@changesets/config': 3.1.1 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.6.3 + + '@changesets/assemble-release-plan@6.0.8': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.6.3 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.4': + dependencies: + '@changesets/apply-release-plan': 7.0.12 + '@changesets/assemble-release-plan': 6.0.8 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.12 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + external-editor: 3.1.0 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.6.3 + spawndamnit: 3.0.1 + term-size: 2.2.1 + + '@changesets/config@3.1.1': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.6.3 + + '@changesets/get-release-plan@4.0.12': + dependencies: + '@changesets/assemble-release-plan': 6.0.8 + '@changesets/config': 3.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': dependencies: - '@babel/core': 7.26.7 - '@babel/helper-plugin-utils': 7.26.5 + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 - '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.7)': + '@changesets/logger@0.1.1': dependencies: - '@babel/core': 7.26.7 - '@babel/helper-plugin-utils': 7.26.5 + picocolors: 1.1.1 - '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.7)': + '@changesets/parse@0.4.1': dependencies: - '@babel/core': 7.26.7 - '@babel/helper-plugin-utils': 7.26.5 + '@changesets/types': 6.1.0 + js-yaml: 3.14.1 - '@babel/runtime@7.26.7': + '@changesets/pre@2.0.2': dependencies: - regenerator-runtime: 0.14.1 + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 - '@babel/template@7.25.9': + '@changesets/read@0.6.5': dependencies: - '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.7 - '@babel/types': 7.26.7 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.1 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 - '@babel/traverse@7.26.7': + '@changesets/should-skip-package@0.1.2': dependencies: - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.5 - '@babel/parser': 7.26.7 - '@babel/template': 7.25.9 - '@babel/types': 7.26.7 - debug: 4.4.0 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 - '@babel/types@7.26.7': + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.1 + prettier: 2.8.8 '@chromatic-com/storybook@3.2.4(react@19.0.0)(storybook@8.5.2(prettier@3.4.2))': dependencies: @@ -3423,150 +4300,78 @@ snapshots: '@emotion/hash@0.9.2': {} - '@esbuild/aix-ppc64@0.23.1': - optional: true - '@esbuild/aix-ppc64@0.24.2': optional: true - '@esbuild/android-arm64@0.23.1': - optional: true - '@esbuild/android-arm64@0.24.2': optional: true - '@esbuild/android-arm@0.23.1': - optional: true - '@esbuild/android-arm@0.24.2': optional: true - '@esbuild/android-x64@0.23.1': - optional: true - '@esbuild/android-x64@0.24.2': optional: true - '@esbuild/darwin-arm64@0.23.1': - optional: true - '@esbuild/darwin-arm64@0.24.2': optional: true - '@esbuild/darwin-x64@0.23.1': - optional: true - '@esbuild/darwin-x64@0.24.2': optional: true - '@esbuild/freebsd-arm64@0.23.1': - optional: true - '@esbuild/freebsd-arm64@0.24.2': optional: true - '@esbuild/freebsd-x64@0.23.1': - optional: true - '@esbuild/freebsd-x64@0.24.2': optional: true - '@esbuild/linux-arm64@0.23.1': - optional: true - '@esbuild/linux-arm64@0.24.2': optional: true - '@esbuild/linux-arm@0.23.1': - optional: true - '@esbuild/linux-arm@0.24.2': optional: true - '@esbuild/linux-ia32@0.23.1': - optional: true - '@esbuild/linux-ia32@0.24.2': optional: true - '@esbuild/linux-loong64@0.23.1': - optional: true - '@esbuild/linux-loong64@0.24.2': optional: true - '@esbuild/linux-mips64el@0.23.1': - optional: true - '@esbuild/linux-mips64el@0.24.2': optional: true - '@esbuild/linux-ppc64@0.23.1': - optional: true - '@esbuild/linux-ppc64@0.24.2': optional: true - '@esbuild/linux-riscv64@0.23.1': - optional: true - '@esbuild/linux-riscv64@0.24.2': optional: true - '@esbuild/linux-s390x@0.23.1': - optional: true - '@esbuild/linux-s390x@0.24.2': optional: true - '@esbuild/linux-x64@0.23.1': - optional: true - '@esbuild/linux-x64@0.24.2': optional: true '@esbuild/netbsd-arm64@0.24.2': optional: true - '@esbuild/netbsd-x64@0.23.1': - optional: true - '@esbuild/netbsd-x64@0.24.2': optional: true - '@esbuild/openbsd-arm64@0.23.1': - optional: true - '@esbuild/openbsd-arm64@0.24.2': optional: true - '@esbuild/openbsd-x64@0.23.1': - optional: true - '@esbuild/openbsd-x64@0.24.2': optional: true - '@esbuild/sunos-x64@0.23.1': - optional: true - '@esbuild/sunos-x64@0.24.2': optional: true - '@esbuild/win32-arm64@0.23.1': - optional: true - '@esbuild/win32-arm64@0.24.2': optional: true - '@esbuild/win32-ia32@0.23.1': - optional: true - '@esbuild/win32-ia32@0.24.2': optional: true - '@esbuild/win32-x64@0.23.1': - optional: true - '@esbuild/win32-x64@0.24.2': optional: true @@ -3700,11 +4505,20 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.6.3) - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) optionalDependencies: typescript: 5.6.3 @@ -3725,6 +4539,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.26.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.26.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + '@mdx-js/react@3.1.0(@types/react@19.0.8)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 @@ -3743,71 +4573,136 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.18.0 - '@rollup/pluginutils@5.1.4(rollup@4.32.0)': + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rollup/pluginutils@5.1.4(rollup@4.40.2)': dependencies: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.32.0 + rollup: 4.40.2 '@rollup/rollup-android-arm-eabi@4.32.0': optional: true + '@rollup/rollup-android-arm-eabi@4.40.2': + optional: true + '@rollup/rollup-android-arm64@4.32.0': optional: true + '@rollup/rollup-android-arm64@4.40.2': + optional: true + '@rollup/rollup-darwin-arm64@4.32.0': optional: true + '@rollup/rollup-darwin-arm64@4.40.2': + optional: true + '@rollup/rollup-darwin-x64@4.32.0': optional: true + '@rollup/rollup-darwin-x64@4.40.2': + optional: true + '@rollup/rollup-freebsd-arm64@4.32.0': optional: true + '@rollup/rollup-freebsd-arm64@4.40.2': + optional: true + '@rollup/rollup-freebsd-x64@4.32.0': optional: true + '@rollup/rollup-freebsd-x64@4.40.2': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.32.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.32.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.40.2': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.32.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.40.2': + optional: true + '@rollup/rollup-linux-arm64-musl@4.32.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.40.2': + optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.32.0': optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.32.0': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.32.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.2': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.32.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.40.2': + optional: true + '@rollup/rollup-linux-x64-gnu@4.32.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.40.2': + optional: true + '@rollup/rollup-linux-x64-musl@4.32.0': optional: true + '@rollup/rollup-linux-x64-musl@4.40.2': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.32.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.40.2': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.32.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.40.2': + optional: true + '@rollup/rollup-win32-x64-msvc@4.32.0': optional: true + '@rollup/rollup-win32-x64-msvc@4.40.2': + optional: true + '@rtsao/scc@1.1.0': {} '@storybook/addon-actions@8.5.2(storybook@8.5.2(prettier@3.4.2))': @@ -3917,13 +4812,13 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@storybook/builder-vite@8.5.2(storybook@8.5.2(prettier@3.4.2))(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@storybook/builder-vite@8.5.2(storybook@8.5.2(prettier@3.4.2))(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: '@storybook/csf-plugin': 8.5.2(storybook@8.5.2(prettier@3.4.2)) browser-assert: 1.2.1 storybook: 8.5.2(prettier@3.4.2) ts-dedent: 2.2.0 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) '@storybook/components@8.5.2(storybook@8.5.2(prettier@3.4.2))': dependencies: @@ -4000,11 +4895,11 @@ snapshots: react-dom: 19.0.0(react@19.0.0) storybook: 8.5.2(prettier@3.4.2) - '@storybook/react-vite@8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(rollup@4.32.0)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@storybook/react-vite@8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(rollup@4.40.2)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) - '@rollup/pluginutils': 5.1.4(rollup@4.32.0) - '@storybook/builder-vite': 8.5.2(storybook@8.5.2(prettier@3.4.2))(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) + '@rollup/pluginutils': 5.1.4(rollup@4.40.2) + '@storybook/builder-vite': 8.5.2(storybook@8.5.2(prettier@3.4.2))(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) '@storybook/react': 8.5.2(@storybook/test@8.5.2(storybook@8.5.2(prettier@3.4.2)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.5.2(prettier@3.4.2))(typescript@5.6.3) find-up: 5.0.0 magic-string: 0.30.17 @@ -4014,7 +4909,7 @@ snapshots: resolve: 1.22.10 storybook: 8.5.2(prettier@3.4.2) tsconfig-paths: 4.2.0 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) optionalDependencies: '@storybook/test': 8.5.2(storybook@8.5.2(prettier@3.4.2)) transitivePeerDependencies: @@ -4124,7 +5019,7 @@ snapshots: optionalDependencies: '@tanstack/react-router': 1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@tanstack/router-plugin@1.99.3(@tanstack/react-router@1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@tanstack/router-plugin@1.99.3(@tanstack/react-router@1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: '@babel/core': 7.26.7 '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.7) @@ -4143,7 +5038,7 @@ snapshots: unplugin: 2.1.2 zod: 3.24.1 optionalDependencies: - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) transitivePeerDependencies: - '@tanstack/react-router' - supports-color @@ -4180,7 +5075,17 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.1 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.26.7 '@testing-library/dom': 10.4.0 @@ -4194,6 +5099,10 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -4217,20 +5126,64 @@ snapshots: dependencies: '@babel/types': 7.26.7 + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.15.21 + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.15.21 + + '@types/deep-eql@4.0.2': {} + '@types/doctrine@0.0.9': {} '@types/estree@1.0.6': {} + '@types/estree@1.0.7': {} + + '@types/express-serve-static-core@5.0.6': + dependencies: + '@types/node': 22.15.21 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@5.0.2': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 5.0.6 + '@types/serve-static': 1.15.7 + + '@types/http-errors@2.0.4': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} '@types/mdx@2.0.13': {} + '@types/mime@1.3.5': {} + + '@types/node@12.20.55': {} + '@types/node@22.12.0': dependencies: undici-types: 6.20.0 + '@types/node@22.15.21': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.0.3(@types/react@19.0.8)': dependencies: '@types/react': 19.0.8 @@ -4241,6 +5194,17 @@ snapshots: '@types/resolve@1.20.6': {} + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.15.21 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 22.15.21 + '@types/send': 0.17.4 + '@types/uuid@9.0.8': {} '@typescript-eslint/eslint-plugin@8.21.0(@typescript-eslint/parser@8.21.0(eslint@9.19.0)(typescript@5.6.3))(eslint@9.19.0)(typescript@5.6.3)': @@ -4326,12 +5290,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.1.1(@types/node@22.12.0)(tsx@4.19.2)': + '@vanilla-extract/babel-plugin-debug-ids@1.2.1': dependencies: - '@vanilla-extract/css': 1.17.1 - '@vanilla-extract/integration': 8.0.0 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) - vite-node: 3.0.4(@types/node@22.12.0)(tsx@4.19.2) + '@babel/core': 7.26.7 + transitivePeerDependencies: + - supports-color + + '@vanilla-extract/compiler@0.1.3(@types/node@22.15.21)(tsx@4.19.2)': + dependencies: + '@vanilla-extract/css': 1.17.3 + '@vanilla-extract/integration': 8.0.3 + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) + vite-node: 3.2.3(@types/node@22.15.21)(tsx@4.19.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -4364,16 +5334,75 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros + '@vanilla-extract/css@1.17.2': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.7 + css-what: 6.1.0 + cssesc: 3.0.0 + csstype: 3.1.3 + dedent: 1.5.3 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/css@1.17.3': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.8 + css-what: 6.1.0 + cssesc: 3.0.0 + csstype: 3.1.3 + dedent: 1.5.3 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + '@vanilla-extract/dynamic@2.1.2': dependencies: '@vanilla-extract/private': 1.0.6 - '@vanilla-extract/integration@8.0.0': + '@vanilla-extract/esbuild-plugin@2.3.14(esbuild@0.24.2)': + dependencies: + '@vanilla-extract/integration': 8.0.2 + optionalDependencies: + esbuild: 0.24.2 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@vanilla-extract/integration@8.0.2': dependencies: '@babel/core': 7.26.7 '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 - '@vanilla-extract/css': 1.17.1 + '@vanilla-extract/css': 1.17.2 + dedent: 1.5.3 + esbuild: 0.24.2 + eval: 0.1.8 + find-up: 5.0.0 + javascript-stringify: 2.1.0 + mlly: 1.7.4 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@vanilla-extract/integration@8.0.3': + dependencies: + '@babel/core': 7.26.7 + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) + '@vanilla-extract/babel-plugin-debug-ids': 1.2.1 + '@vanilla-extract/css': 1.17.3 dedent: 1.5.3 esbuild: 0.24.2 eval: 0.1.8 @@ -4386,15 +5415,23 @@ snapshots: '@vanilla-extract/private@1.0.6': {} + '@vanilla-extract/private@1.0.7': {} + + '@vanilla-extract/private@1.0.8': {} + '@vanilla-extract/recipes@0.5.5(@vanilla-extract/css@1.17.1)': dependencies: '@vanilla-extract/css': 1.17.1 - '@vanilla-extract/vite-plugin@4.0.20(@types/node@22.12.0)(tsx@4.19.2)(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@vanilla-extract/recipes@0.5.5(@vanilla-extract/css@1.17.2)': + dependencies: + '@vanilla-extract/css': 1.17.2 + + '@vanilla-extract/vite-plugin@4.0.20(@types/node@22.15.21)(tsx@4.19.2)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: - '@vanilla-extract/compiler': 0.1.1(@types/node@22.12.0)(tsx@4.19.2) - '@vanilla-extract/integration': 8.0.0 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + '@vanilla-extract/compiler': 0.1.3(@types/node@22.15.21)(tsx@4.19.2) + '@vanilla-extract/integration': 8.0.3 + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -4410,17 +5447,36 @@ snapshots: - tsx - yaml - '@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: '@babel/core': 7.26.7 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) transitivePeerDependencies: - supports-color + '@vitest/browser@3.2.3(playwright@1.52.0)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))(vitest@3.2.3)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@vitest/mocker': 3.2.3(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) + '@vitest/utils': 3.2.3 + magic-string: 0.30.17 + sirv: 3.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.3(@types/node@22.15.21)(@vitest/browser@3.2.3)(jsdom@26.0.0)(tsx@4.19.2) + ws: 8.18.2 + optionalDependencies: + playwright: 1.52.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/expect@2.0.5': dependencies: '@vitest/spy': 2.0.5 @@ -4428,20 +5484,21 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/expect@3.0.4': + '@vitest/expect@3.2.3': dependencies: - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 - chai: 5.1.2 + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.3 + '@vitest/utils': 3.2.3 + chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2))': + '@vitest/mocker@3.2.3(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))': dependencies: - '@vitest/spy': 3.0.4 + '@vitest/spy': 3.2.3 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) '@vitest/pretty-format@2.0.5': dependencies: @@ -4451,28 +5508,29 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - '@vitest/pretty-format@3.0.4': + '@vitest/pretty-format@3.2.3': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.0.4': + '@vitest/runner@3.2.3': dependencies: - '@vitest/utils': 3.0.4 - pathe: 2.0.2 + '@vitest/utils': 3.2.3 + pathe: 2.0.3 + strip-literal: 3.0.0 - '@vitest/snapshot@3.0.4': + '@vitest/snapshot@3.2.3': dependencies: - '@vitest/pretty-format': 3.0.4 + '@vitest/pretty-format': 3.2.3 magic-string: 0.30.17 - pathe: 2.0.2 + pathe: 2.0.3 '@vitest/spy@2.0.5': dependencies: tinyspy: 3.0.2 - '@vitest/spy@3.0.4': + '@vitest/spy@3.2.3': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.3 '@vitest/utils@2.0.5': dependencies: @@ -4487,16 +5545,16 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 - '@vitest/utils@3.0.4': + '@vitest/utils@3.2.3': dependencies: - '@vitest/pretty-format': 3.0.4 + '@vitest/pretty-format': 3.2.3 loupe: 3.1.3 tinyrainbow: 2.0.0 - accepts@1.3.8: + accepts@2.0.0: dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + mime-types: 3.0.1 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.14.0): dependencies: @@ -4513,6 +5571,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -4523,13 +5583,21 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} + ansis@3.10.0: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-query@5.3.0: @@ -4543,8 +5611,6 @@ snapshots: call-bound: 1.0.3 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} - array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -4554,6 +5620,8 @@ snapshots: get-intrinsic: 1.2.7 is-string: 1.1.1 + array-union@2.1.0: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -4633,22 +5701,23 @@ snapshots: dependencies: open: 8.4.2 + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + binary-extensions@2.3.0: {} - body-parser@1.20.3: + body-parser@2.2.0: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 + debug: 4.4.0 http-errors: 2.0.0 - iconv-lite: 0.4.24 + iconv-lite: 0.6.3 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 transitivePeerDependencies: - supports-color @@ -4674,6 +5743,11 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) + bundle-require@5.1.0(esbuild@0.24.2): + dependencies: + esbuild: 0.24.2 + load-tsconfig: 0.2.5 + bytes@3.1.2: {} cac@6.7.14: {} @@ -4707,6 +5781,14 @@ snapshots: loupe: 3.1.3 pathval: 2.0.0 + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -4717,6 +5799,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chardet@0.7.0: {} + check-error@2.1.1: {} chokidar@3.6.0: @@ -4731,8 +5815,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chromatic@11.25.2: {} + ci-info@3.9.0: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -4755,11 +5845,17 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@4.1.1: {} + + commander@9.5.0: {} + concat-map@0.0.1: {} confbox@0.1.8: {} - content-disposition@0.5.4: + consola@3.4.2: {} + + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -4767,10 +5863,14 @@ snapshots: convert-source-map@2.0.0: {} - cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.1: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4813,10 +5913,6 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@3.2.7: dependencies: ms: 2.1.3 @@ -4825,6 +5921,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + decimal.js@10.5.0: {} dedent@1.5.3: {} @@ -4857,12 +5957,16 @@ snapshots: dequal@2.0.3: {} - destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-libc@2.0.3: {} diff@7.0.0: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4881,14 +5985,23 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.88: {} - encodeurl@1.0.2: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} encodeurl@2.0.0: {} + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + entities@4.5.0: {} es-abstract@1.23.9: @@ -4968,7 +6081,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -4998,33 +6111,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild@0.23.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.23.1 - '@esbuild/android-arm': 0.23.1 - '@esbuild/android-arm64': 0.23.1 - '@esbuild/android-x64': 0.23.1 - '@esbuild/darwin-arm64': 0.23.1 - '@esbuild/darwin-x64': 0.23.1 - '@esbuild/freebsd-arm64': 0.23.1 - '@esbuild/freebsd-x64': 0.23.1 - '@esbuild/linux-arm': 0.23.1 - '@esbuild/linux-arm64': 0.23.1 - '@esbuild/linux-ia32': 0.23.1 - '@esbuild/linux-loong64': 0.23.1 - '@esbuild/linux-mips64el': 0.23.1 - '@esbuild/linux-ppc64': 0.23.1 - '@esbuild/linux-riscv64': 0.23.1 - '@esbuild/linux-s390x': 0.23.1 - '@esbuild/linux-x64': 0.23.1 - '@esbuild/netbsd-x64': 0.23.1 - '@esbuild/openbsd-arm64': 0.23.1 - '@esbuild/openbsd-x64': 0.23.1 - '@esbuild/sunos-x64': 0.23.1 - '@esbuild/win32-arm64': 0.23.1 - '@esbuild/win32-ia32': 0.23.1 - '@esbuild/win32-x64': 0.23.1 - esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -5220,7 +6306,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 esutils@2.0.3: {} @@ -5231,44 +6317,48 @@ snapshots: '@types/node': 22.12.0 require-like: 0.1.2 - expect-type@1.1.0: {} + expect-type@1.2.1: {} - express@4.21.2: + express@5.1.0: dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 content-type: 1.0.5 cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 + cookie-signature: 1.2.2 + debug: 4.4.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 + finalhandler: 2.1.0 + fresh: 2.0.0 http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 on-finished: 2.4.1 + once: 1.4.0 parseurl: 1.3.3 - path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.0 range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 + type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color + extendable-error@0.1.7: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -5287,6 +6377,10 @@ snapshots: dependencies: reusify: 1.0.4 + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5297,18 +6391,22 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: + finalhandler@2.1.0: dependencies: - debug: 2.6.9 + debug: 4.4.0 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 statuses: 2.0.1 - unpipe: 1.0.0 transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5325,6 +6423,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.1: dependencies: asynckit: 0.4.0 @@ -5333,7 +6436,22 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} + fresh@2.0.0: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.2: + optional: true fsevents@2.3.3: optional: true @@ -5389,6 +6507,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@11.12.0: {} globals@14.0.0: {} @@ -5400,14 +6527,22 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + goober@2.1.16(csstype@3.1.3): dependencies: csstype: 3.1.3 gopd@1.2.0: {} - graceful-fs@4.2.11: - optional: true + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -5459,6 +6594,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-id@4.1.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5547,6 +6684,8 @@ snapshots: dependencies: call-bound: 1.0.3 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.3 @@ -5569,6 +6708,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.3 @@ -5587,6 +6728,10 @@ snapshots: call-bound: 1.0.3 has-tostringtag: 1.0.2 + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + is-symbol@1.1.1: dependencies: call-bound: 1.0.3 @@ -5608,6 +6753,8 @@ snapshots: call-bound: 1.0.3 get-intrinsic: 1.2.7 + is-windows@1.0.2: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -5625,6 +6772,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + javascript-stringify@2.1.0: {} jotai@2.12.1(@types/react@19.0.8)(react@19.0.0): @@ -5632,8 +6785,17 @@ snapshots: '@types/react': 19.0.8 react: 19.0.0 + joycon@3.1.1: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -5682,6 +6844,10 @@ snapshots: json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -5704,12 +6870,26 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + lodash.sortby@4.7.0: {} + + lodash.startcase@4.4.0: {} + lodash@4.17.21: {} loose-envify@1.4.0: @@ -5742,18 +6922,16 @@ snapshots: dependencies: '@babel/runtime': 7.26.7 - media-typer@0.3.0: {} + media-typer@1.1.0: {} memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 - merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5761,11 +6939,15 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 min-indent@1.0.1: {} @@ -5779,6 +6961,8 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + mlly@1.7.4: dependencies: acorn: 8.14.0 @@ -5788,15 +6972,25 @@ snapshots: modern-ahocorasick@1.1.0: {} - ms@2.0.0: {} + mri@1.2.0: {} + + mrmime@2.0.1: {} ms@2.1.3: {} + mylas@2.1.13: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.8: {} natural-compare@1.4.0: {} - negotiator@0.6.3: {} + negotiator@1.0.0: {} node-releases@2.0.19: {} @@ -5849,6 +7043,10 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -5864,20 +7062,46 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-tmpdir@1.0.2: {} + + outdent@0.5.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.2.7 object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.10 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5894,10 +7118,19 @@ snapshots: path-parse@1.0.7: {} - path-to-regexp@0.1.12: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@8.2.0: {} + + path-type@4.0.0: {} pathe@2.0.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} picocolors@1.1.1: {} @@ -5906,18 +7139,41 @@ snapshots: picomatch@4.0.2: {} + pify@4.0.1: {} + + pirates@4.0.7: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 mlly: 1.7.4 pathe: 2.0.2 + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + polished@4.3.1: dependencies: '@babel/runtime': 7.26.7 possible-typed-array-names@1.0.0: {} + postcss-load-config@6.0.1(postcss@8.5.1)(tsx@4.19.2): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.1 + tsx: 4.19.2 + postcss@8.5.1: dependencies: nanoid: 3.3.8 @@ -5926,6 +7182,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@2.8.8: {} + prettier@3.4.2: {} pretty-format@27.5.1: @@ -5949,19 +7207,23 @@ snapshots: punycode@2.3.1: {} - qs@6.13.0: + qs@6.14.0: dependencies: side-channel: 1.1.0 + quansync@0.2.10: {} + + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@3.0.0: dependencies: bytes: 3.1.2 http-errors: 2.0.0 - iconv-lite: 0.4.24 + iconv-lite: 0.6.3 unpipe: 1.0.0 react-confetti@6.2.2(react@19.0.0): @@ -6011,10 +7273,19 @@ snapshots: react@19.0.0: {} + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + recast@0.23.9: dependencies: ast-types: 0.16.1 @@ -6054,6 +7325,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.10: @@ -6095,6 +7368,42 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.32.0 fsevents: 2.3.3 + rollup@4.40.2: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.2 + '@rollup/rollup-android-arm64': 4.40.2 + '@rollup/rollup-darwin-arm64': 4.40.2 + '@rollup/rollup-darwin-x64': 4.40.2 + '@rollup/rollup-freebsd-arm64': 4.40.2 + '@rollup/rollup-freebsd-x64': 4.40.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.2 + '@rollup/rollup-linux-arm-musleabihf': 4.40.2 + '@rollup/rollup-linux-arm64-gnu': 4.40.2 + '@rollup/rollup-linux-arm64-musl': 4.40.2 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2 + '@rollup/rollup-linux-riscv64-gnu': 4.40.2 + '@rollup/rollup-linux-riscv64-musl': 4.40.2 + '@rollup/rollup-linux-s390x-gnu': 4.40.2 + '@rollup/rollup-linux-x64-gnu': 4.40.2 + '@rollup/rollup-linux-x64-musl': 4.40.2 + '@rollup/rollup-win32-arm64-msvc': 4.40.2 + '@rollup/rollup-win32-ia32-msvc': 4.40.2 + '@rollup/rollup-win32-x64-msvc': 4.40.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.0 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -6138,17 +7447,15 @@ snapshots: semver@7.6.3: {} - send@0.19.0: + send@1.2.0: dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 + debug: 4.4.0 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 + fresh: 2.0.0 http-errors: 2.0.0 - mime: 1.6.0 + mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -6156,12 +7463,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.2: + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.0 + send: 1.2.0 transitivePeerDependencies: - supports-color @@ -6251,19 +7558,40 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 + sirv@3.0.1: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + slash@3.0.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + stackback@0.0.2: {} statuses@2.0.1: {} - std-env@3.8.0: {} + std-env@3.9.0: {} storybook@8.5.2(prettier@3.4.2): dependencies: @@ -6275,6 +7603,18 @@ snapshots: - supports-color - utf-8-validate + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -6319,6 +7659,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.0: dependencies: ansi-regex: 6.1.0 @@ -6335,6 +7679,20 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6343,6 +7701,16 @@ snapshots: symbol-tree@3.2.4: {} + term-size@2.2.1: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} @@ -6351,7 +7719,17 @@ snapshots: tinyexec@0.3.2: {} - tinypool@1.0.2: {} + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.1.0: {} tinyrainbow@1.2.0: {} @@ -6359,32 +7737,58 @@ snapshots: tinyspy@3.0.2: {} + tinyspy@4.0.3: {} + tldts-core@6.1.76: {} tldts@6.1.76: dependencies: tldts-core: 6.1.76 + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + totalist@3.0.1: {} + tough-cookie@5.1.0: dependencies: tldts: 6.1.76 + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + tr46@5.0.0: dependencies: punycode: 2.3.1 + tree-kill@1.2.2: {} + ts-api-utils@2.0.0(typescript@5.6.3): dependencies: typescript: 5.6.3 ts-dedent@2.2.0: {} + ts-interface-checker@0.1.13: {} + + tsc-alias@1.8.15: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.10.0 + globby: 11.1.0 + mylas: 2.1.13 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -6400,9 +7804,36 @@ snapshots: tslib@2.8.1: {} + tsup@8.4.0(postcss@8.5.1)(tsx@4.19.2)(typescript@5.6.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.24.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.0 + esbuild: 0.24.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.1)(tsx@4.19.2) + resolve-from: 5.0.0 + rollup: 4.40.2 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.1 + typescript: 5.6.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.19.2: dependencies: - esbuild: 0.23.1 + esbuild: 0.24.2 get-tsconfig: 4.10.0 optionalDependencies: fsevents: 2.3.3 @@ -6415,10 +7846,11 @@ snapshots: type-fest@2.19.0: {} - type-is@1.6.18: + type-is@2.0.1: dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 typed-array-buffer@1.0.3: dependencies: @@ -6476,6 +7908,10 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: {} + + universalify@0.1.2: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -6512,19 +7948,17 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.18 - utils-merge@1.0.1: {} - uuid@9.0.1: {} vary@1.1.2: {} - vite-node@3.0.4(@types/node@22.12.0)(tsx@4.19.2): + vite-node@3.2.3(@types/node@22.15.21)(tsx@4.19.2): dependencies: cac: 6.7.14 - debug: 4.4.0 - es-module-lexer: 1.6.0 - pathe: 2.0.2 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) transitivePeerDependencies: - '@types/node' - jiti @@ -6539,40 +7973,44 @@ snapshots: - tsx - yaml - vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2): + vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2): dependencies: esbuild: 0.24.2 postcss: 8.5.1 rollup: 4.32.0 optionalDependencies: - '@types/node': 22.12.0 + '@types/node': 22.15.21 fsevents: 2.3.3 tsx: 4.19.2 - vitest@3.0.4(@types/node@22.12.0)(jsdom@26.0.0)(tsx@4.19.2): - dependencies: - '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.0.11(@types/node@22.12.0)(tsx@4.19.2)) - '@vitest/pretty-format': 3.0.4 - '@vitest/runner': 3.0.4 - '@vitest/snapshot': 3.0.4 - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 - chai: 5.1.2 - debug: 4.4.0 - expect-type: 1.1.0 + vitest@3.2.3(@types/node@22.15.21)(@vitest/browser@3.2.3)(jsdom@26.0.0)(tsx@4.19.2): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.3 + '@vitest/mocker': 3.2.3(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2)) + '@vitest/pretty-format': 3.2.3 + '@vitest/runner': 3.2.3 + '@vitest/snapshot': 3.2.3 + '@vitest/spy': 3.2.3 + '@vitest/utils': 3.2.3 + chai: 5.2.0 + debug: 4.4.1 + expect-type: 1.2.1 magic-string: 0.30.17 - pathe: 2.0.2 - std-env: 3.8.0 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinypool: 1.0.2 + tinyglobby: 0.2.14 + tinypool: 1.1.0 tinyrainbow: 2.0.0 - vite: 6.0.11(@types/node@22.12.0)(tsx@4.19.2) - vite-node: 3.0.4(@types/node@22.12.0)(tsx@4.19.2) + vite: 6.0.11(@types/node@22.15.21)(tsx@4.19.2) + vite-node: 3.2.3(@types/node@22.15.21)(tsx@4.19.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.12.0 + '@types/node': 22.15.21 + '@vitest/browser': 3.2.3(playwright@1.52.0)(vite@6.0.11(@types/node@22.15.21)(tsx@4.19.2))(vitest@3.2.3) jsdom: 26.0.0 transitivePeerDependencies: - jiti @@ -6592,6 +8030,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: {} webpack-virtual-modules@0.6.2: {} @@ -6607,6 +8047,12 @@ snapshots: tr46: 5.0.0 webidl-conversions: 7.0.0 + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6658,8 +8104,24 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + ws@8.18.0: {} + ws@8.18.2: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 00000000..9cd4e501 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - "apps/*" + - "packages/*" + - "configs/*" \ No newline at end of file diff --git a/frontend/server/index.js b/frontend/server/index.js deleted file mode 100644 index 908f5d44..00000000 --- a/frontend/server/index.js +++ /dev/null @@ -1,44 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import express from 'express'; -import { createServer as createViteServer } from 'vite'; - -import { cookies } from './cookies.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -const createServer = async () =>{ - const app = express(); - - const vite = await createViteServer({ - server: { middlewareMode: true }, - appType: 'custom', - }); - - app.use(vite.middlewares); - - app.use(express.static(path.resolve(dirname, 'public'))); - - app.use('*', async (req, res, next) => { - try { - const template = fs.readFileSync( - path.resolve(dirname, '../index.html'), - 'utf-8', - ); - const transformedTemplate = await vite.transformIndexHtml(req.originalUrl, template); - res.status(200).set({ 'Content-Type': 'text/html' }) - .end(transformedTemplate); - } catch (e) { - vite.ssrFixStacktrace(e); - next(e); - } - }); - - app.listen(5173); - // eslint-disable-next-line no-console - console.log('\nServer running at \x1b[33mhttp://localhost:5173\x1b[0m \n'); -}; - -createServer(); \ No newline at end of file diff --git a/frontend/src/components/Calendar/context/CalendarContext.ts b/frontend/src/components/Calendar/context/CalendarContext.ts deleted file mode 100644 index a538dd65..00000000 --- a/frontend/src/components/Calendar/context/CalendarContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from 'react'; - -import type { CalendarInfo } from '@/hooks/useCalendar'; -import { useSafeContext } from '@/hooks/useSafeContext'; - -interface CalendarContextProps extends CalendarInfo { - isTableUsed: boolean; -} - -export const CalendarContext = createContext<CalendarContextProps | null>(null); - -export const useCalendarContext = (): CalendarContextProps => useSafeContext(CalendarContext); \ No newline at end of file diff --git a/frontend/src/components/Calendar/context/CalendarProvider.tsx b/frontend/src/components/Calendar/context/CalendarProvider.tsx deleted file mode 100644 index eb464a93..00000000 --- a/frontend/src/components/Calendar/context/CalendarProvider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { PropsWithChildren } from 'react'; - -import { useCalendar } from '../../../hooks/useCalendar'; -import { CalendarContext } from './CalendarContext'; -import type { CalendarSharedInfo } from './SharedCalendarContext'; - -interface CalendarInfo extends PropsWithChildren { - outerContext: Partial<CalendarSharedInfo>; - isTableUsed: boolean; -} - -export const CalendarProvider = ({ outerContext, isTableUsed, children }: CalendarInfo) => { - const calendar = useCalendar(outerContext); - return ( - <CalendarContext.Provider value={{ ...calendar, isTableUsed }}> - {children} - </CalendarContext.Provider> - ); -}; \ No newline at end of file diff --git a/frontend/src/components/Calendar/context/SharedCalendarContext.ts b/frontend/src/components/Calendar/context/SharedCalendarContext.ts deleted file mode 100644 index 64faba13..00000000 --- a/frontend/src/components/Calendar/context/SharedCalendarContext.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; - -export interface CalendarSharedInfo { - selectedDate: Date; - selectedWeek: Date[]; - handleSelectDate: (date: Date) => void; - today: Date; - baseDate: Date; - gotoPrevMonth: () => void; - gotoNextMonth: () => void; -} - -export const SharedCalendarContext = createContext<CalendarSharedInfo | null>(null); - -export const useSharedCalendarContext = () => useSafeContext(SharedCalendarContext); \ No newline at end of file diff --git a/frontend/src/components/Calendar/context/TimeTableContext.ts b/frontend/src/components/Calendar/context/TimeTableContext.ts deleted file mode 100644 index 4141e9c1..00000000 --- a/frontend/src/components/Calendar/context/TimeTableContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; -import type { TimeInfo } from '@/hooks/useSelectTime'; - -export const TimeTableContext = createContext<TimeInfo | null>(null); - -export const useTimeTableContext = (): TimeInfo => useSafeContext(TimeTableContext); \ No newline at end of file diff --git a/frontend/src/components/Calendar/context/TimeTableProvider.tsx b/frontend/src/components/Calendar/context/TimeTableProvider.tsx deleted file mode 100644 index b0ce1a4a..00000000 --- a/frontend/src/components/Calendar/context/TimeTableProvider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { PropsWithChildren } from 'react'; - -import type { TimeInfo } from '@/hooks/useSelectTime'; -import { useSelectTime } from '@/hooks/useSelectTime'; - -import { TimeTableContext } from './TimeTableContext'; - -interface TimeTableInfo extends PropsWithChildren { - outerContext?: TimeInfo; -} - -export const TimeTableProvider = ({ children, outerContext }: TimeTableInfo) => { - const times = useSelectTime(); - return ( - <TimeTableContext.Provider value={outerContext || times}> - {children} - </TimeTableContext.Provider> - ); -}; \ No newline at end of file diff --git a/frontend/src/components/DatePicker/Table/Cell/index.ts b/frontend/src/components/DatePicker/Table/Cell/index.ts deleted file mode 100644 index 9c0c1827..00000000 --- a/frontend/src/components/DatePicker/Table/Cell/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DateCell.tsx'; -export * from './DowCell.tsx'; \ No newline at end of file diff --git a/frontend/src/components/Icon/custom/CheckGraphic.tsx b/frontend/src/components/Icon/custom/CheckGraphic.tsx deleted file mode 100644 index 6839d243..00000000 --- a/frontend/src/components/Icon/custom/CheckGraphic.tsx +++ /dev/null @@ -1,152 +0,0 @@ - -import type { IconProps } from '../Icon.d.ts'; - -export const CheckGraphic = ({ clickable = false, className, width = 24, height = 24 , stroke = "white", fill = "white", ...rest }: IconProps) => { - return ( - <svg - width={width} - height={height || width} - viewBox="0 0 180 180" - xmlns="http://www.w3.org/2000/svg" - aria-label="check-graphic icon" - fill="none" - className={className} - style={{ cursor: clickable ? "pointer" : "default", ...rest.style }} - {...rest} - > - <g clipPath="url(#clip0_894_193)"> - <mask - id="mask0_894_193" - style={{ maskType: "alpha" }} - maskUnits="userSpaceOnUse" - x="0" - y="0" - width="180" - height="180" - > - <rect width="180" height="180" fill="white" /> - </mask> - <g mask="url(#mask0_894_193)"> - <rect width="180" height="180" fill="#F3F6FC" /> - <g filter="url(#filter0_f_894_193)"> - <circle cx="115.2" cy="88.2" r="27" fill="#90C2FF" /> - </g> - <g filter="url(#filter1_f_894_193)"> - <ellipse cx="46.35" cy="132.3" rx="14.85" ry="27" fill="#76E4B8" /> - </g> - <g filter="url(#filter2_d_894_193)"> - <rect - x="77.8491" - y="65.25" - width="72" - height="72" - rx="16" - transform="rotate(10 77.8491 65.25)" - fill="white" - /> - <rect - x="78.2547" - y="65.8292" - width="71" - height="71" - rx="15.5" - transform="rotate(10 78.2547 65.8292)" - stroke="#E5E8EB" - /> - </g> - <mask - id="mask1_894_193" - style={{ maskType: "luminance" }} - maskUnits="userSpaceOnUse" - x="78" - y="78" - width="58" - height="58" - > - <rect - x="87.3398" - y="78.8045" - width="48.6" - height="48.6" - transform="rotate(10 87.3398 78.8045)" - fill="white" - /> - </mask> - <g mask="url(#mask1_894_193)"> - <path - d="M100.976 115.754L93.4197 104.963C92.5984 103.79 92.8446 102.394 94.0175 101.573C95.1904 100.751 96.5864 100.998 97.4077 102.17L103.485 110.85L118.968 100.009C120.141 99.1877 121.537 99.4339 122.358 100.607C123.18 101.78 122.933 103.176 121.76 103.997L104.401 116.152C103.662 116.844 103.029 116.938 102.43 116.833C101.832 116.727 101.269 116.422 100.976 115.754Z" - fill="url(#paint0_linear_894_193)" - /> - </g> - </g> - </g> - <defs> - <filter - id="filter0_f_894_193" - x="-11.8" - y="-38.8" - width="254" - height="254" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur_894_193" /> - </filter> - <filter - id="filter1_f_894_193" - x="-38.5" - y="35.3" - width="169.7" - height="194" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feGaussianBlur stdDeviation="35" result="effect1_foregroundBlur_894_193" /> - </filter> - <filter - id="filter2_d_894_193" - x="47.8791" - y="47.7826" - width="118.344" - height="118.344" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feColorMatrix - in="SourceAlpha" - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" - result="hardAlpha" - /> - <feOffset /> - <feGaussianBlur stdDeviation="10" /> - <feComposite in2="hardAlpha" operator="out" /> - <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0" /> - <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_894_193" /> - <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_894_193" result="shape" /> - </filter> - <linearGradient - id="paint0_linear_894_193" - x1="108.83" - y1="97.4502" - x2="105.322" - y2="117.343" - gradientUnits="userSpaceOnUse" - > - <stop stopColor="#64A8FF" /> - <stop offset="1" stopColor="#3182F6" /> - </linearGradient> - <clipPath id="clip0_894_193"> - <rect width="180" height="180" rx="90" fill="white" /> - </clipPath> - </defs> - </svg> - ); - }; - - CheckGraphic.displayName = 'CheckGraphic'; \ No newline at end of file diff --git a/frontend/src/components/Icon/index.ts b/frontend/src/components/Icon/index.ts deleted file mode 100644 index bea45c96..00000000 --- a/frontend/src/components/Icon/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -export * from "./component/ArrowLeft.tsx"; -export * from "./component/CalendarCheck.tsx"; -export * from "./component/Calendar.tsx"; -export * from "./component/Check.tsx"; -export * from "./component/ChevronDown.tsx"; -export * from "./component/ChevronLeft.tsx"; -export * from "./component/ChevronRight.tsx"; -export * from "./component/CircleCheck.tsx"; -export * from "./component/Clock.tsx"; -export * from "./component/Close.tsx"; -export * from "./component/GoogleCalendar.tsx"; -export * from "./component/Google.tsx"; -export * from "./component/IconDotsMono.tsx"; -export * from "./component/Logo.tsx"; -export * from "./component/Pencil.tsx"; -export * from "./component/PinLocation.tsx"; -export * from "./component/Plus.tsx"; -export * from "./component/Progress.tsx"; -export * from "./component/TriangleWarning.tsx"; -export * from "./component/UserTwo.tsx"; -export * from "./component/TooltipArrow"; \ No newline at end of file diff --git a/frontend/src/components/Icon/png/google-login-icon.png b/frontend/src/components/Icon/png/google-login-icon.png deleted file mode 100644 index 4a311840..00000000 Binary files a/frontend/src/components/Icon/png/google-login-icon.png and /dev/null differ diff --git a/frontend/src/components/Notification/NotificationContext.ts b/frontend/src/components/Notification/NotificationContext.ts deleted file mode 100644 index 54a89da2..00000000 --- a/frontend/src/components/Notification/NotificationContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from 'react'; - -import type { NotificationWithOptionalId } from '@/hooks/useNotification'; - -interface NotificationContextProps { - addNoti: (notification: NotificationWithOptionalId) => void; -} - -export const NotificationContext = createContext<NotificationContextProps | null>(null); \ No newline at end of file diff --git a/frontend/src/components/SegmentControl/Content.tsx b/frontend/src/components/SegmentControl/Content.tsx deleted file mode 100644 index dbd32da2..00000000 --- a/frontend/src/components/SegmentControl/Content.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { PropsWithChildren } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; -import clsx from '@/utils/clsx'; - -import { contentContainerStyle } from './index.css'; -import { SegmentControlContext } from './SegmentControlContext'; - -interface ContentProps extends PropsWithChildren { - value: string; - className?: string; -} - -const Content = ({ value, className, children }: ContentProps) => { - const { selectedValue } = useSafeContext(SegmentControlContext); - if (selectedValue !== value) return null; - return ( - <section className={clsx(contentContainerStyle, className)}> - {children} - </section> - ); -}; - -export default Content; diff --git a/frontend/src/components/SegmentControl/SegmentControlContext.ts b/frontend/src/components/SegmentControl/SegmentControlContext.ts deleted file mode 100644 index 016cc0e0..00000000 --- a/frontend/src/components/SegmentControl/SegmentControlContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from 'react'; - -interface SegmentControlContextProps { - selectedValue: string; - handleSelect: (value: string) => void; -} - -export const SegmentControlContext = createContext<SegmentControlContextProps | null>(null); \ No newline at end of file diff --git a/frontend/src/constants/date.ts b/frontend/src/constants/date.ts deleted file mode 100644 index 53727188..00000000 --- a/frontend/src/constants/date.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type Time = number | 'all' | 'empty'; - -export type WEEKDAY = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT'; - -export const TIMES: readonly number[] = Object.freeze( - new Array(24).fill(0) - .map((_, i) => i)); - -export const WEEK: readonly WEEKDAY[] - = Object.freeze(['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']); - -export const WEEK_MAP: Record<string, string> = Object.freeze({ - 1: '첫째주', - 2: '둘째주', - 3: '셋째주', - 4: '넷째주', - 5: '다섯째주', -}); - -export const MINUTES = Object.freeze( - new Array(4).fill(0) - .map((_, i) => i * 15)); - -export const MINUTES_HALF = (totalTime: number, startTime: number) => Object.freeze( - new Array(totalTime * 2).fill(0) - .map((_, i) => startTime + i * 30), -); -export const TIME_HEIGHT = 66; diff --git a/frontend/src/features/discussion/ui/DiscussionCard/index.tsx b/frontend/src/features/discussion/ui/DiscussionCard/index.tsx deleted file mode 100644 index 3f86e816..00000000 --- a/frontend/src/features/discussion/ui/DiscussionCard/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Link, useParams } from '@tanstack/react-router'; -import { useAtomValue } from 'jotai'; - -import { checkboxAtom } from '@/store/discussion'; - -import type { DiscussionDTO } from '../../model'; -import { linkStyle } from './card.css'; -import { DiscussionLarge } from './DiscussionLarge'; -import { DiscussionSmall } from './DiscussionSmall'; - -interface DiscussionCardProps { - size: 'sm' | 'lg'; - discussion: DiscussionDTO; - rank?: number; -} - -const DiscussionCard = ({ size, discussion, rank }: DiscussionCardProps) => { - const { id } = useParams({ from: '/_main/discussion/$id' }); - const checkedList = useAtomValue(checkboxAtom); - return ( - <Link - className={linkStyle} - params={{ id: id }} - state={{ candidate: { - adjustCount: discussion.usersForAdjust.length, - startDateTime: discussion.startDateTime, - endDateTime: discussion.endDateTime, - selectedParticipantIds: checkedList ?? undefined, - } }} - to='/discussion/candidate/$id' - > - {size === 'lg' ? - <DiscussionLarge discussion={discussion} rank={rank as number} /> - : - <DiscussionSmall discussion={discussion} />} - </Link> - ); -}; - -export default DiscussionCard; \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx b/frontend/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx deleted file mode 100644 index 223312e7..00000000 --- a/frontend/src/features/discussion/ui/DiscussionConfirmCard/BadgeContainer.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Badge } from '@/components/Badge'; -import { Flex } from '@/components/Flex'; -import { getMinuteDiff, getTimeParts, getTimeRangeString } from '@/utils/date'; -import { formatDateToString } from '@/utils/date/format'; - -import { badgeContainerStyle } from './index.css'; - -const BadgeContainer = ({ startDateTime, endDateTime, location }: { - startDateTime: Date; - endDateTime: Date; - location: string; -}) => { - const startTime = getTimeParts(startDateTime); - const endTime = getTimeParts(endDateTime); - return ( - <Flex - align='center' - className={badgeContainerStyle} - gap={250} - justify='flex-start' - > - <Badge iconType='date'> - {formatDateToString(startDateTime)} - </Badge> - <Badge iconType='date'> - {getTimeRangeString(startTime, endTime)} - </Badge> - <Badge iconType='time'> - {getMinuteDiff(startDateTime, endDateTime)} - 분 - </Badge> - {location && <Badge iconType='location'>{location}</Badge>} - </Flex> - ); -}; - -export default BadgeContainer; \ No newline at end of file diff --git a/frontend/src/features/discussion/ui/DiscussionInviteCard/index.tsx b/frontend/src/features/discussion/ui/DiscussionInviteCard/index.tsx deleted file mode 100644 index 5d3609f3..00000000 --- a/frontend/src/features/discussion/ui/DiscussionInviteCard/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useNavigate } from '@tanstack/react-router'; -import { useState } from 'react'; - -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { Modal } from '@/components/Modal'; -import { Text } from '@/components/Text'; -import { addNoti } from '@/store/global/notification'; -import { vars } from '@/theme/index.css'; -import type { Time } from '@/utils/date'; - -import { useInvitationJoinMutation } from '../../api/mutations'; -import Badges from './Badges'; -import { - inputStyle, - modalContentsStyle, - modalFooterStyle, -} from './index.css'; - -export interface DiscussionInviteCardProps { - discussionId: number; - hostName: string; - title: string; - canJoin: boolean; - // Badge props - dateRange: { start: Date; end: Date }; - timeRange: { start: Time; end: Time }; - meetingDuration: number; - requirePassword: boolean; - location?: string; -} - -// TODO: 5회 실패 시 에러 Noti (지금은 500 에러 뜸) -// TODO: Input 입력 숫자 4-6자리로 제한 - -const DiscussionInviteCard = ({ - discussionId, hostName, title, canJoin, requirePassword, ...badgeProps -}: DiscussionInviteCardProps) => { - const navigate = useNavigate(); - const { mutate } = useInvitationJoinMutation(); - const [password, setPassword] = useState(''); - const handleJoinClick = () => { - mutate( - { body: { discussionId, password: password === '' ? undefined : password } }, - { - onSuccess: (data) => { - if (data.isSuccess) { - navigate({ to: '/discussion/$id', params: { id: discussionId.toString() } }); - } else { - addNoti({ type: 'error', title: `비밀번호가 일치하지 않습니다 - ${data.failedCount}회 시도` }); - } - }, - }, - ); - }; - - return ( - <Modal - isOpen - subTitle={`${hostName}님이 일정 조율에 초대했어요!`} - title={title} - > - <DiscussionInviteCardContents {...badgeProps} /> - <DiscussionInviteCardFooter - canJoin={canJoin} - onJoinClick={handleJoinClick} - requirePassword={requirePassword} - setPassword={setPassword} - /> - </Modal> - ); -}; - -export default DiscussionInviteCard; - -interface DiscussionInviteCardContentsProps { - dateRange: { start: Date; end: Date }; - timeRange: { start: Time; end: Time }; - meetingDuration: number; - location?: string; -} -const DiscussionInviteCardContents = (props: DiscussionInviteCardContentsProps) => ( - <Modal.Contents className={modalContentsStyle}> - <Badges {...props} /> - </Modal.Contents> -); - -interface DiscussionInviteCardFooterProps { - canJoin: boolean; - requirePassword: boolean; - onJoinClick: () => void; - setPassword: (password: string) => void; -} -const DiscussionInviteCardFooter = ({ - canJoin, - requirePassword, - onJoinClick, - setPassword, -}: DiscussionInviteCardFooterProps) => ( - <Modal.Footer className={modalFooterStyle({ disabled: !canJoin })}> - {!canJoin && ( - <Text color={vars.color.Ref.Red[500]} typo='b2M'> - 인원이 꽉 찼어요 - </Text> - )} - <Flex - align='flex-end' - direction='row' - gap={500} - > - {requirePassword && ( - <input - className={inputStyle} - onChange={(e) => setPassword(e.target.value)} - placeholder='숫자 4~6자리 비밀번호' - /> - )} - <Button - disabled={!canJoin} - onClick={onJoinClick} - size='xl' - > - 초대 수락하기 - </Button> - </Flex> - </Modal.Footer> -); diff --git a/frontend/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx b/frontend/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx deleted file mode 100644 index b2dfc5d7..00000000 --- a/frontend/src/features/discussion/ui/DiscussionRank/RankTableRow.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; -import { formatDateToString, formatDateToTimeString } from '@/utils/date/format'; - -import type { DiscussionDTO } from '../../model'; -import { - tableCellStyle, - tableCellTextStyle, - tableRowStyle, -} from './index.css'; - -const RankAdjustable = ({ users }: { users: DiscussionDTO['usersForAdjust'] }) => { - const ADJUSTMENT_LENGTH = users.length; - const isRecommend = ADJUSTMENT_LENGTH === 0; - const AdjustmentText = () => { - if (isRecommend) return '모두 가능해요'; - return ( - <> - <span style={{ color: vars.color.Ref.Primary[500] }}> - {ADJUSTMENT_LENGTH} - 명 - </span> - 만 조율하면 돼요 - </> - ); - }; - return ( - <> - <Text - className={tableCellTextStyle} - color={vars.color.Ref.Netural[800]} - typo='t2' - > - <AdjustmentText /> - </Text> - <Flex gap={200}> - {!isRecommend && users.map((user) => <Chip color='black' key={user.id}>{user.name}</Chip>)} - </Flex> - </> - ); -}; - -export const RankTableRow = ( - { discussion, rank }: { discussion: DiscussionDTO; rank: number }, -) => ( - <Flex - as='tr' - className={tableRowStyle} - key={`${rank}-${discussion.startDateTime}`} - width='100%' - > - <td className={tableCellStyle({ width: 56 })}> - <Text color={vars.color.Ref.Netural[600]} typo='b3M'> - {rank + 4} - 위 - </Text> - </td> - <td className={tableCellStyle({ width: 'full' })}> - <RankAdjustable users={discussion.usersForAdjust} /> - </td> - <td className={tableCellStyle({ width: 158 })}> - <Text color={vars.color.Ref.Netural[600]} typo='b2R'> - {formatDateToString(new Date(discussion.startDateTime))} - </Text> - </td> - <td className={tableCellStyle({ width: 158 })}> - <Text color={vars.color.Ref.Netural[600]} typo='b2R'> - {formatDateToTimeString(new Date(discussion.startDateTime))} - {' '} - - - {' '} - {formatDateToTimeString(new Date(discussion.endDateTime))} - </Text> - </td> - </Flex> -); - \ No newline at end of file diff --git a/frontend/src/features/login/api/index.ts b/frontend/src/features/login/api/index.ts deleted file mode 100644 index 3d35ecf0..00000000 --- a/frontend/src/features/login/api/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { request } from '@/utils/fetch'; - -import type { JWTResponse } from '../model'; - -export const loginApi = { - getJWT: async (code: string): Promise<JWTResponse> => { - const response = await request.post('/api/v1/login', { - body: { code }, - }); - return response; - }, -}; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx b/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx deleted file mode 100644 index 8fa16208..00000000 --- a/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { TIME_HEIGHT } from '@/constants/date'; -import { getDateParts, isAllday } from '@/utils/date'; -import { calcPositionByDate } from '@/utils/date/position'; - -import type { PersonalEventResponse } from '../../model'; -import { CalendarCard } from '../CalendarCard'; - -const calcSize = (height: number) => { - if (height < TIME_HEIGHT) return 'sm'; - if (height < TIME_HEIGHT * 2.5) return 'md'; - return 'lg'; -}; - -const DefaultCard = ( - { card, start, end }: { card: PersonalEventResponse; start: Date; end: Date }, -) => { - const { x: sx, y: sy } = calcPositionByDate(start); - const { y: ey } = calcPositionByDate(end); - const height = ey - sy; - return ( - <CalendarCard - calendarId={card.calendarId} - endTime={new Date(card.endDateTime)} - id={card.id} - size={calcSize(height)} - startTime={new Date(card.startDateTime)} - status={card.isAdjustable ? 'adjustable' : 'fixed'} - style={{ - width: 'calc((100% - 72px) / 7 - 0.5rem)', - height, - position: 'absolute', - left: `calc(((100% - 72px) / 7 * ${sx}) + 72px)`, - top: 16 + sy, - }} - title={card.title} - /> - ); -}; - -export const CalendarCardList = ({ cards }: { cards: PersonalEventResponse[] }) => ( - <> - {cards.filter((card) => !isAllday(card.startDateTime, card.endDateTime)) - .map((card) => { - const start = new Date(card.startDateTime); - const end = new Date(card.endDateTime); - const { year: sy, month: sm, day: sd } = getDateParts(start); - const { year: ey, month: em, day: ed } = getDateParts(end); - - if (sd !== ed) { - return ( - <> - <DefaultCard - card={card} - end={new Date(sy, sm, sd, 23, 59)} - key={`${card.id}-start`} - start={start} - /> - <DefaultCard - card={card} - end={end} - key={`${card.id}-end`} - start={new Date(ey, em, ed, 0, 0)} - /> - </> - ); - } - - return ( - <DefaultCard - card={card} - end={end} - key={card.id} - start={start} - /> - ); - })} - </> -); \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx b/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx deleted file mode 100644 index 5c9e66b2..00000000 --- a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; -import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; -import { isNextWeek, setDateOnly } from '@/utils/date'; -import { calcPositionByDate } from '@/utils/date/position'; - -import { discussionBoxStyle } from './index.css'; - -export const CalendarDiscussionBox = () => { - const { selectedDateRange } = useDiscussionContext(); - const { selectedWeek } = useSharedCalendarContext(); - if (!selectedDateRange) return null; - - // TODO: 테스트 코드 작성 - const { start, end } = selectedDateRange; - if (start > selectedWeek[6] || end < selectedWeek[0]) return null; - - const startDate = start > selectedWeek[0] ? start : setDateOnly(start, selectedWeek[0]); - const endDate = end < selectedWeek[6] ? end : setDateOnly(end, selectedWeek[6]); - - const { x: sx, y: sy } = calcPositionByDate(startDate); - const { x: ex, y: ey } = calcPositionByDate(endDate); - const dayDiff = isNextWeek(start, end) ? 7 - sx : ex - sx; - const height = ey - sy; - - return ( - <div - className={discussionBoxStyle} - style={{ - position: 'absolute', - top: 16 + sy, - left: `calc(((100% - 72px) / 7 * ${sx}) + 72px)`, - width: `calc((100% - 72px) / 7 * ${dayDiff + 1})`, - height, - }} - /> - ); -}; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts b/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts deleted file mode 100644 index 7d446b04..00000000 --- a/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { TIME_HEIGHT } from '@/constants/date'; -import { getTimeParts } from '@/utils/date'; - -export const useScrollToCurrentTime = () => { - const tableRef = useRef<HTMLDivElement | null>(null); - const { hour, minute } = getTimeParts(new Date()); - const offset = 6.5 + (hour + minute / 60) * TIME_HEIGHT; - - useEffect(() => { - if (tableRef.current) { - tableRef.current.scrollTo({ - top: offset - 5 * TIME_HEIGHT, - behavior: 'smooth', - }); - } - }, [offset]); - - return { tableRef, height: offset }; -}; diff --git a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx b/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx deleted file mode 100644 index 5fa44658..00000000 --- a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useOngoingQuery } from '@/features/shared-schedule/api/queries'; -import type { OngoingSchedule } from '@/features/shared-schedule/model'; -import { useClickOutside } from '@/hooks/useClickOutside'; -import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; -import { parseTime } from '@/utils/date'; - -import { OngoingCardItem } from './OngoingCardItem'; - -export const OngoingCardList = () => { - const { data, isPending } = useOngoingQuery(1, 3, 'ALL'); - const { selectedId, setSelectedId, handleSelectDateRange, reset } = useDiscussionContext(); - - const handleClickSelect = (discussion: OngoingSchedule | null) => { - if (!discussion) { - setSelectedId(null); - reset(); - return; - } - const start = new Date(discussion.dateRangeStart); - const end = new Date(discussion.dateRangeEnd); - const { hour: sh, minute: sm } = parseTime(discussion.timeRangeStart); - const { hour: eh, minute: em } = parseTime(discussion.timeRangeEnd); - start.setHours(sh); - start.setMinutes(sm); - end.setHours(eh); - end.setMinutes(em); - - setSelectedId(discussion.discussionId); - handleSelectDateRange(start, end); - }; - - const cardRef = useClickOutside<HTMLDivElement>(()=>handleClickSelect(null)); - - if (isPending || !data) return null; - - return data.ongoingDiscussions.map((discussion) => ( - <OngoingCardItem - isSelected={selectedId === discussion.discussionId} - key={discussion.discussionId} - onClick={()=>handleClickSelect(discussion)} - ref={cardRef} - {...discussion} - /> - )); -}; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx b/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx deleted file mode 100644 index c2991856..00000000 --- a/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; - -import type { PopoverType } from '../../model'; -import { buttonStyle } from './index.css'; - -interface PopoverButtonProps { - type: PopoverType; - onClickCreate: () => void; - onClickEdit: () => void; - onClickDelete: () => void; -} - -export const PopoverButton = ({ type, ...handlers }: PopoverButtonProps)=>( - type === 'add' ? <Button className={buttonStyle} onClick={handlers.onClickCreate}>저장</Button> : ( - <Flex - gap={100} - justify='flex-end' - width='100%' - > - <Button - className={buttonStyle} - onClick={handlers.onClickDelete} - style='weak' - variant='destructive' - > - 삭제 - </Button> - <Button className={buttonStyle} onClick={handlers.onClickEdit}>저장</Button> - </Flex> - ) -); \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx b/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx deleted file mode 100644 index d6602aaf..00000000 --- a/frontend/src/features/my-calendar/ui/SchedulePopover/PopoverForm.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Checkbox } from '@/components/Checkbox'; -import { Flex } from '@/components/Flex'; -import Input from '@/components/Input'; -import { Text } from '@/components/Text'; -import { Toggle } from '@/components/Toggle'; -import type { FormRef } from '@/hooks/useFormRef'; -import { vars } from '@/theme/index.css'; -import { formatDateToTimeString } from '@/utils/date/format'; - -import type { PersonalEventRequest } from '../../model'; -import { cardStyle, inputStyle } from './index.css'; - -// TODO: Form Context 관리 -const AdjustableCheckbox = ( - { valuesRef, handleChange }: FormRef<PersonalEventRequest>, -) => ( - <Checkbox - defaultChecked={valuesRef.current.isAdjustable} - inputProps={{ - name: 'isAdjustable', - onChange: (e) => handleChange({ name: 'isAdjustable', value: e.target.checked }), - }} - size='sm' - > - 시간 조정 가능 - </Checkbox> -); - -const GoogleCalendarToggle = ( - { valuesRef, handleChange }: FormRef<PersonalEventRequest>, -) => - <Toggle - defaultChecked={valuesRef.current.syncWithGoogleCalendar} - inputProps={{ - name: 'syncWithGoogleCalendar', - onChange: (e) => handleChange({ name: 'syncWithGoogleCalendar', value: e.target.checked }), - }} - />; - -export const PopoverForm = ({ valuesRef, handleChange }: FormRef<PersonalEventRequest>) => - <> - <Flex - align='flex-end' - className={cardStyle} - direction='column' - gap={400} - > - <input - className={inputStyle} - defaultValue={valuesRef.current.title} - name='title' - onChange={(e) => handleChange({ name: 'title', value: e.target.value })} - placeholder='새 일정' - /> - <Input.Multi - borderPlacement='container' - label='시간 설정' - separator='~' - type='text' - > - <Input.Multi.InputField - readOnly - value={formatDateToTimeString(new Date(valuesRef.current.startDateTime))} - /> - <Input.Multi.InputField - readOnly - value={formatDateToTimeString(new Date(valuesRef.current.endDateTime))} - /> - </Input.Multi> - <AdjustableCheckbox handleChange={handleChange} valuesRef={valuesRef} /> - </Flex> - <Flex className={cardStyle} justify='space-between'> - <Text color={vars.color.Ref.Netural[600]} typo='caption'>구글 캘린더 연동</Text> - <GoogleCalendarToggle handleChange={handleChange} valuesRef={valuesRef} /> - </Flex> - </>; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/ui/SchedulePopover/index.tsx b/frontend/src/features/my-calendar/ui/SchedulePopover/index.tsx deleted file mode 100644 index e787c8c3..00000000 --- a/frontend/src/features/my-calendar/ui/SchedulePopover/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Flex } from '@/components/Flex'; -import { useFormRef } from '@/hooks/useFormRef'; -import { calcPositionByDate } from '@/utils/date/position'; - -import { useSchedulePopover } from '../../api/hooks'; -import type { PersonalEventRequest, PopoverType } from '../../model'; -import { backgroundStyle, containerStyle } from './index.css'; -import { PopoverButton } from './PopoverButton'; -import { PopoverForm } from './PopoverForm'; -import { Title } from './Title'; - -type DefaultEvent = Omit<PersonalEventRequest, 'endDateTime' | 'startDateTime'>; - -interface SchedulePopoverProps extends Pick<PersonalEventRequest, 'endDateTime' | 'startDateTime'> { - scheduleId?: number; - values?: DefaultEvent; - setIsOpen: (isOpen: boolean) => void; - type: PopoverType; - reset?: () => void; -} - -const initEvent = (values?: DefaultEvent): DefaultEvent => { - if (values) return values; - return { - title: '제목 없음', - isAdjustable: false, - syncWithGoogleCalendar: true, - }; -}; - -const Background = ({ setIsOpen, reset }: Pick<SchedulePopoverProps, 'setIsOpen' | 'reset'>) => ( - <Flex - className={backgroundStyle} - onClick={() => { - setIsOpen(false); - reset?.(); - }} - /> -); - -export const SchedulePopover = ( - { setIsOpen, reset, scheduleId, type, values, ...event }: SchedulePopoverProps, -) => { - const startDate = new Date(event.startDateTime); - const { x: sx } = calcPositionByDate(startDate); - const { valuesRef, handleChange } = useFormRef<PersonalEventRequest>({ - startDateTime: event.startDateTime, - endDateTime: event.endDateTime, - ...initEvent(values), - }); - const { handleClickCreate, handleClickEdit, handleClickDelete } = useSchedulePopover({ - setIsOpen, - reset, - scheduleId, - valuesRef, - }); - return( - <> - <dialog - className={containerStyle} - style={{ - position: 'fixed', - left: `calc((100vw - 72px - 17.75rem) / 7 * ${sx + 1})`, - top: '50vh', - }} - > - <Title type={type} /> - <PopoverForm handleChange={handleChange} valuesRef={valuesRef} /> - <PopoverButton - onClickCreate={handleClickCreate} - onClickDelete={handleClickDelete} - onClickEdit={handleClickEdit} - type={type} - /> - </dialog> - <Background reset={reset} setIsOpen={setIsOpen} /> - </> - ); -}; \ No newline at end of file diff --git a/frontend/src/features/shared-schedule/model/upcomingSchedules.ts b/frontend/src/features/shared-schedule/model/upcomingSchedules.ts deleted file mode 100644 index 0480710b..00000000 --- a/frontend/src/features/shared-schedule/model/upcomingSchedules.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; - -const SharedEventDtoSchema = z.object({ - id: z.number(), - startDateTime: z.string(), - endDateTime: z.string(), -}); - -export const UpcomingScheduleSchema = z.object({ - discussionId: z.number(), - title: z.string(), - meetingMethodOrLocation: z.union([z.string(), z.null()]), - sharedEventDto: SharedEventDtoSchema, - participantPictureUrls: z.array(z.string()), -}); - -export const UpcomingSchedulesResponseSchema = z.object({ - data: z.array(UpcomingScheduleSchema), -}); - -export type UpcomingSchedule = z.infer<typeof UpcomingScheduleSchema>; -export type UpcomingSchedulesResponse = z.infer<typeof UpcomingSchedulesResponseSchema>; diff --git a/frontend/src/features/timeline-schedule/ui/TimelineContext.ts b/frontend/src/features/timeline-schedule/ui/TimelineContext.ts deleted file mode 100644 index a10e1395..00000000 --- a/frontend/src/features/timeline-schedule/ui/TimelineContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; - -interface TimelineContextProps { - isConfirmedSchedule: boolean; - handleGoBack: () => void; -} - -export const TimelineContext = createContext<TimelineContextProps | null>(null); - -export const useTimelineContext = () => useSafeContext(TimelineContext); \ No newline at end of file diff --git a/frontend/src/features/timeline-schedule/ui/index.tsx b/frontend/src/features/timeline-schedule/ui/index.tsx deleted file mode 100644 index c9053fb5..00000000 --- a/frontend/src/features/timeline-schedule/ui/index.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useCanGoBack, useRouter } from '@tanstack/react-router'; -import type { PropsWithChildren } from 'react'; - -import { Flex } from '@/components/Flex'; -import { Close } from '@/components/Icon'; -import { useClickOutside } from '@/hooks/useClickOutside'; -import { vars } from '@/theme/index.css'; - -import { useCandidateDetailQuery } from '../api/queries'; -import type { CandidateDetailResponse, Participant } from '../model'; -import { - closeButtonStyle, - containerStyle, - contentContainerStyle, - headerStyle, - topBarStyle, -} from './index.css'; -import TimelineContent from './TimelineContent'; -import { TimelineContext, useTimelineContext } from './TimelineContext'; -import { splitParticipantsBySelection } from './timelineHelper'; - -// TODO: context로 옮길 수 있는 prop들 찾아서 옮기기 -interface TimelineScheduleModalProps extends PropsWithChildren { - discussionId: number; - startDateTime: string; - endDateTime: string; - selectedParticipantIds?: number[]; - isConfirmedSchedule: boolean; -} - -const TimelineScheduleModal = ({ - discussionId, startDateTime, endDateTime, selectedParticipantIds, children, isConfirmedSchedule, -}: TimelineScheduleModalProps) => { - const router = useRouter(); - const canGoBack = useCanGoBack(); - const handleGoBack = () => canGoBack && router.history.back(); - const notClickableRef = useClickOutside<HTMLDivElement>(handleGoBack); - - const { data, isPending } = useCandidateDetailQuery( - discussionId, startDateTime, endDateTime, selectedParticipantIds, - ); - - if (isPending || !data) return <div className={containerStyle} />; - const { checkedParticipants, uncheckedParticipants } = splitParticipantsBySelection( - data.participants, - selectedParticipantIds, - ); - - return ( - <TimelineContext.Provider value={{ isConfirmedSchedule, handleGoBack }}> - <div - className={containerStyle} - ref={notClickableRef} - > - {children} - <Content - {...data} - checkedParticipants={checkedParticipants} - uncheckedParticipants={uncheckedParticipants} - /> - </div> - </TimelineContext.Provider> - ); -}; - -const TopBar = ({ children }: PropsWithChildren) =>{ - const { handleGoBack } = useTimelineContext(); - return ( - <Flex - align='center' - className={topBarStyle} - justify='flex-start' - > - {children} - <Close - className={closeButtonStyle} - clickable - fill={vars.color.Ref.Netural[500]} - onClick={handleGoBack} - width={24} - /> - </Flex> - ); -}; - -interface ContentProps extends CandidateDetailResponse { - checkedParticipants: Participant[]; - uncheckedParticipants: Participant[]; -} - -const Content = ({ - checkedParticipants, - uncheckedParticipants, - ...props -}: ContentProps) => ( - <Flex - className={contentContainerStyle} - direction='column' - justify='flex-start' - width='full' - > - <TimelineContent - checkedParticipants={checkedParticipants} - conflictEnd={props.endDateTime} - conflictStart={props.startDateTime} - uncheckedParticipants={uncheckedParticipants} - /> - </Flex> -); - -const Header = ({ children }: PropsWithChildren) => ( - <div className={headerStyle}> - {children} - </div> -); - -TimelineScheduleModal.TopBar = TopBar; -TimelineScheduleModal.Header = Header; - -export default TimelineScheduleModal; \ No newline at end of file diff --git a/frontend/src/hooks/useFormRef.ts b/frontend/src/hooks/useFormRef.ts deleted file mode 100644 index 96200a97..00000000 --- a/frontend/src/hooks/useFormRef.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useRef } from 'react'; - -// Form의 value로는 다양한 값이 올 수 있습니다. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type FormValues<T> = { [K in keyof T]: any }; -export type ChangeEvent<T> = { name: keyof T; value: T[keyof T] }; - -export interface FormRef<T> { - valuesRef: { current: FormValues<T> }; - handleChange: ({ name, value }: ChangeEvent<T>) => void; -} - -export const useFormRef = <T>(initialValues: FormValues<T>): FormRef<T> => { - const valuesRef = useRef<FormValues<T>>({ ...initialValues }); - - const handleChange = ({ name, value }: ChangeEvent<T>) => { - valuesRef.current[name] = value; - }; - - return { valuesRef, handleChange }; -}; diff --git a/frontend/src/hooks/useNotification.ts b/frontend/src/hooks/useNotification.ts deleted file mode 100644 index aa7f9daa..00000000 --- a/frontend/src/hooks/useNotification.ts +++ /dev/null @@ -1,52 +0,0 @@ - -import { useCallback, useReducer } from 'react'; - -import type { NotificationProps } from '../components/Notification'; - -export type NotificationWithOptionalId = NotificationProps & { - id?: number; -}; - -export type NotificationWithId = NotificationProps & { - id: number; -}; - -type NotificationState = { - notifications: NotificationWithId[]; -}; - -export type NotificationAction = { type: 'ADD_NOTIFICATION'; notification: NotificationWithId } -| { type: 'REMOVE_NOTIFICATION'; id: number }; - -export const notificationReducer = (state: NotificationState, action: NotificationAction) => { - switch (action.type) { - case 'ADD_NOTIFICATION': { - return { notifications: [...state.notifications, action.notification] }; - } - case 'REMOVE_NOTIFICATION': { - const newNotifications = state.notifications - .filter((notification) => notification.id !== action.id); - return { notifications: newNotifications }; - } - default: - return state; - } -}; - -export const useNotification = () => { - const [state, dispatch] = useReducer(notificationReducer, { notifications: [] }); - - const addNoti = (notification: NotificationWithOptionalId) => { - // TODO: 유니크한 id 생성하기. (따닥 이슈) - const defaultId = notification.id || new Date().getTime(); - - dispatch({ type: 'ADD_NOTIFICATION', notification: { ...notification, id: defaultId } }); - setTimeout(() => { - dispatch({ type: 'REMOVE_NOTIFICATION', id: defaultId }); - }, 3000); - }; - - const memoizedAddNoti = useCallback(addNoti, []); - - return { state, addNoti: memoizedAddNoti }; -}; \ No newline at end of file diff --git a/frontend/src/hooks/usePagination.ts b/frontend/src/hooks/usePagination.ts deleted file mode 100644 index f4a90cc6..00000000 --- a/frontend/src/hooks/usePagination.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useState } from 'react'; - -export const usePagination = ( - initialPage: number, -) => { - const [currentPage, setCurrentPage] = useState(initialPage); - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; - - return { - currentPage, - handlePageChange, - }; -}; diff --git a/frontend/src/pages/ErrorPage/index.tsx b/frontend/src/pages/ErrorPage/index.tsx deleted file mode 100644 index 7d555cf3..00000000 --- a/frontend/src/pages/ErrorPage/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Link } from '@tanstack/react-router'; - -import Button from '@/components/Button'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import { vars } from '@/theme/index.css'; - -const ErrorPage = () => ( - <Flex - align='center' - direction='column' - gap={700} - height='100vh' - > - <img - alt='에러를 나타내는 이미지' - height={180} - src='/images/assets/error.webp' - width={180} - /> - <Flex - align='center' - direction='column' - gap={300} - > - <Text color={vars.color.Ref.Netural[800]} typo='h3'>유효하지 않은 링크입니다.</Text> - <Text color={vars.color.Ref.Netural[500]} typo='b2M'>링크가 삭제되거나 변경되었어요.</Text> - </Flex> - <Button - as={Link} - size='lg' - style='weak' - to='/' - > - 홈으로 - </Button> - </Flex> -); - -export default ErrorPage; \ No newline at end of file diff --git a/frontend/src/pages/HomePage/index.tsx b/frontend/src/pages/HomePage/index.tsx deleted file mode 100644 index 07e0c0db..00000000 --- a/frontend/src/pages/HomePage/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ - -import FinishedSchedules from '@/features/shared-schedule/ui/FinishedSchedules'; -import OngoingSchedules from '@/features/shared-schedule/ui/OngoingSchedules'; - -import { containerStyle } from './index.css'; -import UpcomingSection from './UpcomingSection'; - -const HomePage = () => ( - <div className={containerStyle}> - <UpcomingSection /> - <OngoingSchedules /> - <FinishedSchedules /> - </div> -); - -export default HomePage; \ No newline at end of file diff --git a/frontend/src/pages/MyCalendarPage/DiscussionContext.ts b/frontend/src/pages/MyCalendarPage/DiscussionContext.ts deleted file mode 100644 index fb6086a6..00000000 --- a/frontend/src/pages/MyCalendarPage/DiscussionContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react'; -import { createContext } from 'react'; - -import { useSafeContext } from '@/hooks/useSafeContext'; -import type { DateRangeReturn } from '@/hooks/useSelectDateRange'; - -interface DiscussionContextProps extends DateRangeReturn { - selectedId: number | null; - setSelectedId: Dispatch<SetStateAction<number | null>>; -} - -export const DiscussionContext = createContext<DiscussionContextProps | null>(null); -export const useDiscussionContext = () => useSafeContext(DiscussionContext); \ No newline at end of file diff --git a/frontend/src/pages/UpcomingScheduleDetailPage/index.tsx b/frontend/src/pages/UpcomingScheduleDetailPage/index.tsx deleted file mode 100644 index dd33788a..00000000 --- a/frontend/src/pages/UpcomingScheduleDetailPage/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useParams } from '@tanstack/react-router'; - -import { Chip } from '@/components/Chip'; -import { Flex } from '@/components/Flex'; -import { Text } from '@/components/Text'; -import TimelineScheduleModal from '@/features/timeline-schedule/ui'; -import { formatDateToDdayString } from '@/utils/date/format'; - -import Header from './Header'; -import { backdropStyle } from './index.css'; - -interface ScheduleInfo { - title: string; - startDateTime: string; - endDateTime: string; -}; - -const UpcomingScheduleDetailPage = (scheduleInfo: ScheduleInfo) => { - const { id } = useParams({ from: '/_main/upcoming-schedule/$id' }); - const start = new Date(scheduleInfo.startDateTime); - const end = new Date(scheduleInfo.endDateTime); - - return ( - <> - <div className={backdropStyle} /> - <TimelineScheduleModal - discussionId={Number(id)} - isConfirmedSchedule={true} - {...scheduleInfo} - > - <TimelineScheduleModal.TopBar> - <TopBarContent end={end} scheduleInfo={scheduleInfo} /> - </TimelineScheduleModal.TopBar> - <TimelineScheduleModal.Header> - <Header endDateTime={end} startDateTime={start} /> - </TimelineScheduleModal.Header> - </TimelineScheduleModal> - </> - ); -}; - -const TopBarContent = ({ scheduleInfo, end }: { scheduleInfo: ScheduleInfo; end: Date }) => ( - <Flex align='center' gap={200}> - <Text typo='t1'>{scheduleInfo.title}</Text> - <Chip - color='coolGray' - radius='max' - size='md' - style='weak' - > - {formatDateToDdayString(end)} - </Chip> - </Flex> -); - -export default UpcomingScheduleDetailPage; diff --git a/frontend/src/routes/_main/upcoming-schedule/$id.tsx b/frontend/src/routes/_main/upcoming-schedule/$id.tsx deleted file mode 100644 index 87622df9..00000000 --- a/frontend/src/routes/_main/upcoming-schedule/$id.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { createFileRoute, useLocation } from '@tanstack/react-router'; - -import GlobalNavBar from '@/layout/GlobalNavBar'; -import UpcomingScheduleDetailPage from '@/pages/UpcomingScheduleDetailPage'; - -const UpcomingDetail = () => { - const { upcomingScheduleDetail: routeState } = useLocation().state; - if (!routeState) return <div />; - - return ( - <> - <GlobalNavBar> - <GlobalNavBar.MyCalendarLink /> - <GlobalNavBar.NewDiscussionLink /> - </GlobalNavBar> - <UpcomingScheduleDetailPage {...routeState} /> - </> - ); -}; - -export const Route = createFileRoute('/_main/upcoming-schedule/$id')({ - component: UpcomingDetail, -}); - diff --git a/frontend/src/theme/index.ts b/frontend/src/theme/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/utils/auth/index.ts b/frontend/src/utils/auth/index.ts deleted file mode 100644 index 4dd4a981..00000000 --- a/frontend/src/utils/auth/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { JWTResponse } from '@/features/login/model'; - -const ACCESS_TOKEN_KEY = 'accessToken'; - -export const setLogin = ({ accessToken, expiredAt }: JWTResponse)=> { - localStorage.setItem(ACCESS_TOKEN_KEY, JSON.stringify({ - accessToken, expiredAt, - })); -}; - -export const getAccessToken = () => { - const token = localStorage.getItem(ACCESS_TOKEN_KEY); - if (!token) return null; - - const { accessToken, expiredAt } = JSON.parse(token); - if (new Date(expiredAt) < new Date()) { - localStorage.removeItem('accessToken'); - return null; - } - - return accessToken; -}; - -export const isLogin = () => { - const accessToken = getAccessToken(); - if (!accessToken) return false; - return true; -}; - -export const logout = () => { - localStorage.removeItem(ACCESS_TOKEN_KEY); -}; \ No newline at end of file diff --git a/frontend/src/utils/common/index.ts b/frontend/src/utils/common/index.ts deleted file mode 100644 index d59e793e..00000000 --- a/frontend/src/utils/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const isObjectEmpty = (obj: object) => Object.keys(obj).length === 0; \ No newline at end of file diff --git a/frontend/src/utils/date/index.ts b/frontend/src/utils/date/index.ts deleted file mode 100644 index dc4d2f5b..00000000 --- a/frontend/src/utils/date/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './date'; -export * from './time'; diff --git a/frontend/src/utils/date/position.ts b/frontend/src/utils/date/position.ts deleted file mode 100644 index b52c1b6f..00000000 --- a/frontend/src/utils/date/position.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TIME_HEIGHT } from '@/constants/date'; - -export const calcPositionByDate = (date: Date | null) => { - if (!date) return { x: 0, y: 0 }; - - const day = date.getDay(); - const hour = date.getHours(); - const minute = date.getMinutes(); - - const height = TIME_HEIGHT * hour + TIME_HEIGHT * (minute / 60); - - return { x: day, y: height }; -}; \ No newline at end of file diff --git a/frontend/src/utils/error/HTTPError.ts b/frontend/src/utils/error/HTTPError.ts deleted file mode 100644 index 279a3f12..00000000 --- a/frontend/src/utils/error/HTTPError.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ErrorCode } from '@/constants/error'; -import { errorMessages } from '@/constants/error'; - -export interface HTTPErrorProps { - code: ErrorCode; - message: string; -} - -export class HTTPError extends Error { - #status: number; - - constructor ({ status, code, message }: { status: number } & HTTPErrorProps) { - super(errorMessages[code] || message); - this.name = 'HTTPError'; - this.#status = status; - } - - isLoginError = () => this.#status === 401; -} diff --git a/frontend/src/utils/error/handleError.ts b/frontend/src/utils/error/handleError.ts deleted file mode 100644 index c7e2cbe6..00000000 --- a/frontend/src/utils/error/handleError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { redirect } from '@tanstack/react-router'; -import { ZodError } from 'zod'; - -import { DEFAULT_ERROR_MESSAGE } from '@/constants/error'; -import { addNoti } from '@/store/global/notification'; -import { HTTPError } from '@/utils/error'; - -export const handleError = (error: unknown) => { - if (error instanceof ZodError) { - // 개발자 디버깅용 콘솔 출력 - // eslint-disable-next-line no-console - console.error(error); - return; - } - - if (!(error instanceof HTTPError)) { - addNoti({ type: 'error', title: DEFAULT_ERROR_MESSAGE }); - return; - } - - if (error.isLoginError()) redirect({ to: '/login' }); - else addNoti({ type: 'error', title: error.message }); -}; diff --git a/frontend/src/utils/error/index.ts b/frontend/src/utils/error/index.ts deleted file mode 100644 index 47cc08a4..00000000 --- a/frontend/src/utils/error/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './HTTPError'; \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json deleted file mode 100644 index 6688576e..00000000 --- a/frontend/tsconfig.app.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "types": ["vitest/globals"], - "paths": { - "@/*": ["src/*"], - "@components/*": ["src/components/*"], - "@hooks/*": ["src/hooks/*"], - "@utils/*": ["src/utils/*"], - "@theme/*": ["src/theme/*"], - }, - - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - }, - "include": ["src"] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.base.json similarity index 67% rename from frontend/tsconfig.node.json rename to frontend/tsconfig.base.json index db0becc8..6a719127 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.base.json @@ -1,24 +1,22 @@ { "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + /* common transfiling options */ "target": "ES2022", - "lib": ["ES2023"], "module": "ESNext", - "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, "moduleDetection": "force", - "noEmit": true, + "isolatedModules": true, + "jsx": "react-jsx", /* Linting */ + "useDefineForClassFields": true, + "skipLibCheck": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} + } +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef600..6b840dfa 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "files": [], "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "apps/client/tsconfig.app.json" }, + { "path": "apps/client/tsconfig.node.json" }, + { "path": "packages/ui/tsconfig.json" }, + { "path": "packages/theme/tsconfig.json" }, + { "path": "packages/calendar/tsconfig.json" }, + { "path": "packages/date-time/tsconfig.json" } ] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 93bd7408..00000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; -import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; -import react from '@vitejs/plugin-react'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { defineConfig } from 'vitest/config'; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [ - TanStackRouterVite({ - autoCodeSplitting: true, - routeFileIgnorePrefix: '@', - routesDirectory: './src/routes', - generatedRouteTree: './src/routeTree.gen.ts', - }), - react(), - vanillaExtractPlugin(), - ], - resolve: { - alias: { - '@': path.resolve(dirname, 'src'), - '@components': path.resolve(dirname, 'src/components'), - '@hooks': path.resolve(dirname, 'src/hooks'), - '@utils': path.resolve(dirname, 'src/utils'), - '@theme': path.resolve(dirname, 'src/theme'), - }, - }, - test: { - globals: true, - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - environment: 'jsdom', - }, -}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..62194a4c --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + projects: ['apps/*/vitest.config*.ts', 'packages/*/vitest.config*.ts'], + }, +}); \ No newline at end of file diff --git a/script/deploy/backend_deploy.sh b/script/deploy/backend_deploy.sh new file mode 100644 index 00000000..4fe612c2 --- /dev/null +++ b/script/deploy/backend_deploy.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -e # 오류 발생 시 스크립트 중단 + +# 디렉토리 설정 +APP_DIR=~/app +BACKEND_DIR=$APP_DIR/backend +BACKEND_JAR=$(find $APP_DIR/backend/build/libs -name "*.jar" | tail -n 1) +LOG_DIR=/var/log/endolphin + +# 타임스탬프 생성 (버전 관리용) +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKEND_RELEASE_DIR=$BACKEND_DIR/releases/$TIMESTAMP +BACKEND_CURRENT_LINK=$BACKEND_DIR/current + +# 로그 디렉토리 생성 +sudo mkdir -p $LOG_DIR +sudo chown -R ec2-user:ec2-user $LOG_DIR + +# 백엔드 배포 디렉토리 구조 생성 +mkdir -p $BACKEND_RELEASE_DIR + +# 현재 배포 상태 기록 (롤백용) +PREVIOUS_BACKEND=$(readlink -f $BACKEND_CURRENT_LINK || echo "") + +# 백엔드 JAR 파일 복사 및 심볼릭 링크 생성 +echo "Deploying backend..." +cp $BACKEND_JAR $BACKEND_RELEASE_DIR/application.jar +ln -sfn $BACKEND_RELEASE_DIR $BACKEND_CURRENT_LINK + +# 서비스 파일 업데이트 및 재시작 +cat > /tmp/endolphin.service << EOF +[Unit] +Description=Endolphin Spring Boot Application +After=network.target + +[Service] +User=ec2-user +WorkingDirectory=${BACKEND_CURRENT_LINK} +ExecStart=/usr/bin/java -Dspring.profiles.active=prod -jar ${BACKEND_CURRENT_LINK}/application.jar +SuccessExitStatus=143 +TimeoutStopSec=10 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +sudo mv /tmp/endolphin.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl restart endolphin + +# 백엔드 헬스 체크 +echo "Checking backend health..." +MAX_RETRIES=30 +COUNT=0 +while [ $COUNT -lt $MAX_RETRIES ]; do + if curl -s http://localhost:8080/health | grep -q "UP"; then + echo "Backend is healthy!" + break + fi + echo "Waiting for backend to become healthy..." + sleep 2 + COUNT=$((COUNT+1)) +done + +if [ $COUNT -eq $MAX_RETRIES ]; then + echo "Backend failed to start properly! Rolling back..." + if [ -n "$PREVIOUS_BACKEND" ] && [ -d "$PREVIOUS_BACKEND" ]; then + ln -sfn $PREVIOUS_BACKEND $BACKEND_CURRENT_LINK + sudo systemctl restart endolphin + echo "Rolled back to previous version." + else + echo "No previous version found for rollback!" + fi + exit 1 +fi + +# 오래된 백엔드 릴리스 정리 (최근 5개만 유지) +echo "Cleaning up old backend releases..." +cd $BACKEND_DIR/releases && ls -t | tail -n +6 | xargs rm -rf || true + +echo "백엔드 배포가 성공적으로 완료되었습니다!" \ No newline at end of file diff --git a/script/deploy/ecosystem.config.js b/script/deploy/ecosystem.config.js new file mode 100644 index 00000000..5623096a --- /dev/null +++ b/script/deploy/ecosystem.config.js @@ -0,0 +1,14 @@ +module.exports = { + apps: [ + { + name: "unjemanna-web-server", + script: "./frontend/dist/server/index.js", + cwd: "~/app", + env: { + NODE_ENV: "production", + BASE: "/", + PORT: 5173, + }, + }, + ], +}; diff --git a/script/deploy/frontend_deploy.sh b/script/deploy/frontend_deploy.sh new file mode 100644 index 00000000..70769715 --- /dev/null +++ b/script/deploy/frontend_deploy.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e # 오류 발생 시 스크립트 중단 + +APP_DIR=~/app +DIST_DIR=$APP_DIR/frontend/dist +SSR_DIR=$APP_DIR/frontend/dist/server # SSR 서버 디렉토리 +SSR_SERVICE_NAME=unjemanna-web-server # PM2에서 관리할 SSR 서버 이름 + +BACKUP_DIR=$APP_DIR/frontend/backups +TIMESTAMP=$(date +"%Y%m%d%H%M%S") + +# 프론트엔드 백업 +echo "기존 프론트엔드 백업 중..." +mkdir -p $BACKUP_DIR +sudo tar -czf $BACKUP_DIR/frontend_$TIMESTAMP.tar.gz -C $DIST_DIR . + +# 프론트엔드 배포 +echo "프론트엔드 배포 중..." + +# SSR 서버 배포 +echo "SSR 서버 배포 중..." + +# SSR 서버 재시작 (PM2 사용) +echo "SSR 서버 재시작 중..." +if pm2 list | grep -q $SSR_SERVICE_NAME; then + pm2 restart ecosystem.config.js +else + pm2 start ecosystem.config.js +fi +echo "SSR 서버가 성공적으로 재시작되었습니다!" +pm2 save + +# Nginx 설정 +echo "Nginx 구성 중..." +sudo cp $APP_DIR/deploy/nginx/frontend.conf /etc/nginx/conf.d/frontend.conf +sudo cp $APP_DIR/deploy/nginx/backend.conf /etc/nginx/conf.d/backend.conf +if sudo nginx -t; then + sudo systemctl reload nginx + echo "프론트엔드 배포가 성공적으로 완료되었습니다!" +else + echo "Nginx 구성 테스트 실패! 롤백 중..." + # 롤백 실행 + sudo rm -rf $DIST_DIR/* + sudo tar -xzf $BACKUP_DIR/frontend_$TIMESTAMP.tar.gz -C $DIST_DIR + echo "이전 버전으로 롤백했습니다." + exit 1 +fi + +# 오래된 백업 정리 (최근 5개만 유지) +echo "오래된 프론트엔드 백업 정리 중..." +cd $BACKUP_DIR && ls -t | tail -n +6 | xargs rm -f || true \ No newline at end of file diff --git a/script/deploy/nginx/backend.conf b/script/deploy/nginx/backend.conf new file mode 100644 index 00000000..7ffa19b2 --- /dev/null +++ b/script/deploy/nginx/backend.conf @@ -0,0 +1,21 @@ +location /api { + proxy_pass http://localhost:8080/api; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + add_header 'Access-Control-Allow-Origin' 'https://unjemanna.site' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, PUT' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'https://unjemanna.site'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PUT, OPTIONS, PATCH'; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization'; + add_header 'Access-Control-Max-Age' 86400; + return 204; + } +} diff --git a/script/deploy/nginx/frontend.conf b/script/deploy/nginx/frontend.conf new file mode 100644 index 00000000..35d5c4cf --- /dev/null +++ b/script/deploy/nginx/frontend.conf @@ -0,0 +1,52 @@ +server { + if ($host = www.unjemanna.site) { + return 301 https://$host$request_uri; + } + + + if ($host = api.unjemanna.site) { + return 301 https://$host$request_uri; + } + + + if ($host = unjemanna.site) { + return 301 https://$host$request_uri; + } + + + listen 80; + server_name unjemanna.site www.unjemanna.site api.unjemanna.site; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name unjemanna.site www.unjemanna.site; + + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + location / { + proxy_pass http://localhost:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + # 보안 헤더 추가 + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options SAMEORIGIN; + add_header X-XSS-Protection "1; mode=block"; + } + + include /etc/nginx/conf.d/backend.conf; + include /etc/nginx/conf.d/cert.conf; +} \ No newline at end of file