diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index af4cff92..51c2dba2 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -4,12 +4,15 @@ on: push: branches: - test + pull_request: + branches: + - test workflow_dispatch: inputs: branch: - description: "Select the branch to build and deploy" + description: 'Select the branch to build and deploy' required: true - default: "main" + default: 'main' type: choice options: - test @@ -24,7 +27,8 @@ jobs: - name: Checkout code uses: actions/checkout@v3 with: - ref: ${{ github.event.inputs.branch || github.ref_name }} + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref || github.event.inputs.branch || 'test' }} - name: Set up Node.js uses: actions/setup-node@v3 @@ -55,13 +59,23 @@ jobs: echo "Building for branch: ${{ github.event.inputs.branch || github.ref_name }}" npm run build - - name: Deploy + - name: Deploy to s3 + uses: jakejarvis/s3-sync-action@master + with: + args: --delete env: - SSH_PRIVATE_KEY: ${{ secrets.TEST_SSH_SECRETE }} - EC2_HOST: ${{ secrets.TEST_EC2_HOST }} - EC2_USERNAME: ${{ secrets.TEST_EC2_USERNAME }} + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} + SOURCE_DIR: 'dist' + + - name: Invalidate CloudFront Cache run: | - echo -e "$SSH_PRIVATE_KEY" > key.pem - chmod 600 key.pem - scp -o StrictHostKeyChecking=no -i key.pem -r dist/* $EC2_USERNAME@$EC2_HOST:/var/www/html/ - ssh -o StrictHostKeyChecking=no -i key.pem $EC2_USERNAME@$EC2_HOST "sudo systemctl restart nginx" + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.AWS_CLOUDFRONT_ID }} \ + --paths "/*" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} diff --git a/bundles.html b/bundles.html new file mode 100644 index 00000000..f2101a2a --- /dev/null +++ b/bundles.html @@ -0,0 +1,4949 @@ + + + + + + + + Rollup Visualizer + + + +
+ + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..623e11f9 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,16 @@ +'use client'; +import { router } from '@/routes/router'; +import { refreshAuthAtom } from '@/store/auth'; +import { useSetAtom } from 'jotai'; +import { useEffect } from 'react'; +import { RouterProvider } from 'react-router-dom'; + +export const App = () => { + const refreshAuth = useSetAtom(refreshAuthAtom); + + useEffect(() => { + refreshAuth(); + }, []); + + return ; +}; \ No newline at end of file diff --git a/src/api/apiInstance.ts b/src/api/apiInstance.ts index f67edc10..29135d9e 100644 --- a/src/api/apiInstance.ts +++ b/src/api/apiInstance.ts @@ -1,7 +1,6 @@ -import { handleCookieOnRedirect } from '@/utils/cookie'; - import { NavigateFunction } from 'react-router-dom'; +import { API_BASE_URL } from '@/api/config'; import { ServerIntendedError } from '@/api/types'; import { PATH } from '@/routes/path'; import axios, { @@ -28,7 +27,7 @@ const processQueue = (error: AxiosError | null): void => { if (error) { prom.reject(error); } else { - prom.resolve(axios(prom.config)); + prom.resolve(apiInstance(prom.config)); } }); @@ -46,7 +45,8 @@ export const navigate = (path: string) => { }; const apiInstance: AxiosInstance = axios.create({ - baseURL: `/api/`, + baseURL: `${API_BASE_URL}/api/`, + withCredentials: true, headers: { 'Content-Type': 'application/json', }, @@ -55,9 +55,9 @@ const apiInstance: AxiosInstance = axios.create({ apiInstance.interceptors.request.use( (config) => { - const token = sessionStorage.getItem('Authorization'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; + // 헤더가 남아있을 경우 삭제 + if (config.headers?.Authorization) { + delete config.headers.Authorization; } return config; }, @@ -92,7 +92,6 @@ apiInstance.interceptors.response.use( if (error.response.status === 401) { if (code === 100) { navigate(PATH.ENROLL); - return Promise.reject(error); } @@ -112,14 +111,11 @@ apiInstance.interceptors.response.use( return apiInstance .post('v1/reissue') .then(() => { - handleCookieOnRedirect(); - processQueue(null); return apiInstance(originalRequest); }) .catch((reissueError: AxiosError) => { processQueue(reissueError); - sessionStorage.removeItem('Authorization'); navigate(PATH.LOGIN); console.error('reissue error', reissueError); @@ -132,7 +128,7 @@ apiInstance.interceptors.response.use( // 리프레시 토큰이 만료됨 // sessionStorage.removeItem('Authorization'); - // navigate(PATH.LOGIN); + navigate(PATH.LOGIN); return Promise.reject(error); } } diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 00000000..0a65a71c --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,11 @@ +const getApiBaseUrl = () => { + const host = window.location.hostname; + + if (host === 'localhost') return import.meta.env.VITE_LOCAL_URL; + if (host === 'test.codemonster.site') return import.meta.env.VITE_TEST_URL; + if (host === 'codemonster.site') return import.meta.env.VITE_BASE_URL; + + return 'https://api.test.codemonster.site'; +}; + +export const API_BASE_URL = getApiBaseUrl(); diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts index 77b60bd6..6cc24d3a 100644 --- a/src/api/dashboard.ts +++ b/src/api/dashboard.ts @@ -1,6 +1,6 @@ import { isDevMode } from '@/utils/cookie.ts'; -import { subjectMock, teamArticlesMock, teamInfoMock } from '@/api/mocks.ts'; +import { subjectMock } from '@/api/mocks.ts'; import apiInstance from './apiInstance'; import { ITeamInfo } from './team'; @@ -22,6 +22,9 @@ export interface IArticle { articleTitle: string; articleBody: string; createdDate: string; + // TODO: 이미지 하나 허용으로 롤백 + // imageUrls: string[] | null; + imageUrl: string | null; memberName: string; memberImage: string; isAuthor: boolean; @@ -29,12 +32,31 @@ export interface IArticle { export interface IArticlesByDateResponse { content: IArticle[]; - page: { - size: number; - number: number; - totalElements: number; - totalPages: number; + empty: boolean; + first: boolean; + last: boolean; + number: number; + numberOfElements: number; + pageable: { + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; }; + size: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + totalElements: number; + totalPages: number; } export interface ITopicResponse { @@ -43,6 +65,9 @@ export interface ITopicResponse { articleTitle: string; articleBody: string; createdDate: string; + // TODO: 이미지 하나 허용으로 롤백 + // imageUrls: string[] | null; + imageUrl: string | null; authorName: string; authorImageUrl: string; } @@ -52,11 +77,11 @@ export const getTeamInfoAndTags = async ( year: number, month: number ): Promise => { - if (isDevMode()) { - await new Promise((r) => setTimeout(r, 1000)); + // if (isDevMode()) { + // await new Promise((r) => setTimeout(r, 1000)); - return teamInfoMock.data; - } + // return teamInfoMock.data; + // } const res = await apiInstance.get>( `/v1/teams/${teamId}/team-page`, @@ -71,9 +96,9 @@ export const getArticlesByDate = async ( date: string, page: number ): Promise => { - if (isDevMode()) { - return teamArticlesMock.data; - } + // if (isDevMode()) { + // return teamArticlesMock.data; + // } const res = await apiInstance.get>( `/v1/articles/${teamId}/by-date`, diff --git a/src/api/mocks.ts b/src/api/mocks.ts index bd120f5a..d195a71b 100644 --- a/src/api/mocks.ts +++ b/src/api/mocks.ts @@ -1082,7 +1082,7 @@ export const teamRecruitDetailMock = { teamRecruitId: 1, teamRecruitTitle: 'test', teamRecruitBody: - '

▶️ 스터디 소개

  • 이런 사람들과 함께해요
    • 팀 내에 운영진이 서비스를 직접 운영합니다
    • 사용자가 속한 팀에 정해진 날짜에 맞추어, 수준에 맞는 class에 맞는 문제를 제공해요
  • 이렇게 참여해요
    • 정해진 날짜에 코테를 풀고 서비스에 기록을 하기만 하면 돼요
    • 백준 class1 ~ class4의 문제를 모두 제공해요
    • 코드블럭과 편집기, 링크/이미지 첨부 기능을 모두 제공해요
    • 운영진이 디코를 통해 푸는 날짜의 알림을 보내드려요
  • 이런 혜택을 얻을 수 있어요
    • 문제를 직접 찾거나 고를 필요 없이 정해지는 문제를 풀 수 있어요
    • 코테를 혼자 매일 푸는 건 어렵잖아요. 팀원들과 함께 날짜에 맞춰 문제를 풀어요
    • 같은 문제를 푼 팀원들의 풀이를 보며 이해를 높일 수 있어요
    • 풀이에 도움이 되거나 추가적인 정보가 담긴 자료를 운영진들에게 공유받을 수 있어요



▶️ 스터디 온보딩

  • 이렇게 진행돼요
    • 코드몬스터 웹사이트와 디스코드를 병행해요. 100% 비대면인만큼 부담 없이 참여할 수 있어요! \uD83D\uDE0A
  • 이렇게 함께할 수 있어요
    • 현재 2days / 4days / 6days 이렇게 세 가지 커리큘럼으로 나뉘어 스터디가 진행되고 있습니다
    • 방 이름은 일주일 중에 문제를 푸는 횟수를 뜻해요
    • 참여자가 함께하고 싶은 횟수(날짜)를 자유롭게 고를 수 있어요
    • 스터디 중간에 참여하는 팀을 얼마든지 바꿀 수 있어요 :)



▶️ 사이트 온보딩

코몬 팀이 기획/개발한 ‘코드몬스터’ 서비스를 이용해 스터디를 진행하게 돼요

  • 아주 간단히 사이트를 설명해 드릴게요
    • 정해진 요일마다 코테 문제를 풀며 실력을 향상하고, 회고를 통해 배운 내용을 자기 것으로 만드는 것을 목표로 해요
    • 팀원은 팀장이 지정한 문제를 확인 후 풀이를 작성하며, 캘린더를 통해 기록을 모아볼 수 있어요
  • 이렇게 가입하면 돼요
    • 카카오톡 계정으로 회원가입 후 닉네임을 본명으로 설정해주세요. 디스코드 프로필과 동일하게 이름을 설정하기 위함이니 양해 부탁드려요
    • 로그인을 완료했다면, 이 페이지 아래에 있는 [신청하기]에서 신청글을 작성해 보세요
    • 아래 템플릿을 복사해서 내용을 채워주세요!! 간단한 인사도 남겨주세요
      • (간단한 인사를 남겨주세요! 원하는 직무, 지나온 경험, 배우고 싶은 점, 이루고 싶은 꿈 모두 좋습니다)- 코테 경험:\r\n- 백준 티어:\r\n- 풀고 싶은 class:
    • 신청 완료 후 하루~이틀을 기다려 주시면, 운영진이 빠르게 방으로 초대해 드려요



원하는 날짜를 고르셨나요?\r\n그럼 저희가 소통할 수 있는 디스코드로 함께 가보아요



▶️ 디스코드 활용 안내

디스코드 서버는 실시간 소통과 피드백을 위한 공간입니다. 스터디 참여를 위해서 꼭 가입이 필요해요!


  • 디스코드는 이렇게 활용해요
    • 스터디 일정 알림 수신: 새 문제 업데이트, 스터디 일정 등 공지
    • 팀원 간 커뮤니케이션: 질의응답, 코드 리뷰, 회고 진행
  • 이렇게 디스코드에 들어오면 돼요
    • 해당 링크로 서버에 접속해 주세요(https://discord.gg/7yuEFZnKCR)
    • 서버 프로필을 본명 / ndays로 설정해 주세요 (임시완이 4days에 참여하고 싶다면 임시완 / 4days )
    • 프로필에 무사히 들어왔다면, [\uD83D\uDC4B\uD83C\uDFFB 입장-인사방]으로 와주세요
    • 팀 가입에서 적었던 말들을 간단히 한번 더 적어주세요! 팀원들 간의 소통을 위함이에요 :)
      • (간단한 인사를 남겨주세요! 원하는 직무, 지나온 경험, 배우고 싶은 점, 이루고 싶은 꿈 모두 좋습니다)\r\n\r\n- 코테 경험:\r\n- 백준 티어:\r\n- 풀고 싶은 class:
    • 조금만 기다려 주시면 새 권한을 부여해서 코테 방 입장을 도와드릴게요!



디코에 들어오셔서 인사도 남겨주셨나요?\r\n저희 운영진이 빠르게 환영해 드릴게요!!!\r\n



▶️ 문의 및 지원

코테 스터디 가입 이후 문의나 지원이 필요하시면 아래의 방법을 따라주세요.

  • 운영 관련 질문: 디스코드 내 서버 운영자에게 DM
  • 코테 관련 기술 질문: 디스코드 내 코테 운영자에게 DM



\uD83D\uDE47\uD83C\uDFFB‍♂️\r\n함께하게 되어서 영광입니다 !!!!\r\n\r\n이제 여정이 모두 끝났어요 \uD83D\uDC4F\uD83C\uDFFB\uD83D\uDC4F\uD83C\uDFFB\uD83D\uDC4F\uD83C\uDFFB\uD83D\uDC4F\uD83C\uDFFB\uD83D\uDC4F\uD83C\uDFFB\r\n잘 따라와 주셔서 다시 감사의 말을 전해요\r\n\r\n부족한 점, 에러, 문의 사항은 언제든 운영진에게 남겨주시면\r\n적극 반영해서 더욱 올바른 방향으로 서비스를 이끌도록 하겠습니다\r\n\r\n저희와 함께 한 문제씩 풀며, 밝은 미래를 준비해 보아요\r\n잘 부탁드려요 ! \uD83E\uDEE1


', + '

image


붙여넣은 이미지

', chatUrl: 'test', isRecruiting: true, // TODO: memberNickName: 'test', diff --git a/src/api/mypage.ts b/src/api/mypage.ts index a2481250..001a6d47 100644 --- a/src/api/mypage.ts +++ b/src/api/mypage.ts @@ -1,7 +1,4 @@ -import { isDevMode } from '@/utils/cookie.ts'; - import apiInstance from '@/api/apiInstance'; -import { myArticlesMock, myPageTeamMock } from '@/api/mocks.ts'; import { ServerResponse } from '@/api/types'; export type TeamAbstraction = { @@ -12,9 +9,9 @@ export type TeamAbstraction = { }; export const queryMyTeamInfo = async () => { - if (isDevMode()) { - return myPageTeamMock.data; - } + // if (isDevMode()) { + // return myPageTeamMock.data; + // } const res = await apiInstance.get>( @@ -49,9 +46,9 @@ export type MyArticleResponse = { }; export const queryMyArticles = async (teamId: number, page: number) => { - if (isDevMode()) { - return myArticlesMock.data; - } + // if (isDevMode()) { + // return myArticlesMock.data; + // } const res = await apiInstance.get>( `v1/articles/${teamId}/my-page`, diff --git a/src/api/postings.ts b/src/api/postings.ts index 2e2f2663..c0453b2b 100644 --- a/src/api/postings.ts +++ b/src/api/postings.ts @@ -1,14 +1,11 @@ -import { isDevMode } from '@/utils/cookie.ts'; - import apiInstance from '@/api/apiInstance'; -import { createPostMock, mutatePostMock } from '@/api/mocks.ts'; import { ServerResponse } from '@/api/types'; type PostingMutationArg = { teamId: number; articleTitle: string; articleBody: string; - // images: File[] | null; + images: File[] | null; }; type PostingMutationResp = { @@ -19,18 +16,35 @@ export const createPost = async ({ teamId, articleTitle, articleBody, + images, }: PostingMutationArg) => { - if (isDevMode()) { - await new Promise((r) => setTimeout(r, 1000)); - return createPostMock.data; + const formData = new FormData(); + + formData.append('teamId', teamId.toString()); + formData.append('articleTitle', articleTitle); + formData.append('articleBody', articleBody); + if (images) { + images.forEach((img) => { + // formData.append('images', img); + formData.append('image', img); + }); } + // else { + // formData.append('images', ''); + // } + + // if (isDevMode()) { + // await new Promise((r) => setTimeout(r, 1000)); + // return createPostMock.data; + // } const res = await apiInstance.post>( 'v1/articles', + formData, { - teamId, - articleTitle, - articleBody, + headers: { + 'Content-Type': 'multipart/form-data', + }, } ); @@ -41,22 +55,39 @@ export const mutatePost = async ({ teamId, articleTitle, articleBody, + images, articleId, }: PostingMutationArg & { articleId: number; }) => { - if (isDevMode()) { - await new Promise((r) => setTimeout(r, 1000)); - return mutatePostMock.data; + const formData = new FormData(); + + formData.append('teamId', teamId.toString()); + formData.append('articleId', articleId.toString()); + formData.append('articleTitle', articleTitle); + formData.append('articleBody', articleBody); + if (images) { + images.forEach((img) => { + // formData.append('images', img); + formData.append('image', img); + }); } + // else { + // formData.append('images', ''); + // } + + // if (isDevMode()) { + // await new Promise((r) => setTimeout(r, 1000)); + // return mutatePostMock.data; + // } const res = await apiInstance.put>( - 'v1/articles', + `v1/articles/${articleId}`, + formData, { - teamId, - articleId, - articleTitle, - articleBody, + headers: { + 'Content-Type': 'multipart/form-data', + }, } ); diff --git a/src/api/presignedurl.ts b/src/api/presignedurl.ts deleted file mode 100644 index e6810bf6..00000000 --- a/src/api/presignedurl.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { isDevMode } from '@/utils/cookie.ts'; - -import apiInstance from '@/api/apiInstance.ts'; -import { ServerResponse } from '@/api/types.ts'; -import axios from 'axios'; - -export type PresignedUrlRequest = { - fileName: string; - contentType: string; -}; - -type PresignedUrlRes = { - fileName: string; - presignedUrl: string; - contentType: string; -}; - -type RequestPresignedUrlParam = { - requests: PresignedUrlRequest; - imageCategory: string; - file: File; -}; - -export const requestPresignedUrl = async ({ - requests, - imageCategory, - file, -}: RequestPresignedUrlParam) => { - if (isDevMode()) { - return { - contentType: file.type, - fileName: file.name, - presignedUrl: - 'https://pnu-comon-s3-bucket-v3.s3.ap-northeast-2.amazonaws.com/article/9ae59a88-f3f2-4c1a-a14e-31b29e127111_header_logo.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20250329T104755Z&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Expires=600&X-Amz-Credential=AKIARWPFIQ2ROKFM4CEK%2F20250329%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Signature=e056a362a9c1762525fa231fab8212012ab37e03cc875ca3be6fb328ec1d96b5', - }; - } - - const res = await apiInstance.post>( - `v1/image/presigned-url?imageCategory=${imageCategory}`, - { ...requests } - ); - - return res.data.data; -}; - -type S3RequestParam = { - url: string; - contentType: string; - file: File; -}; - -export const toS3 = async ({ url, contentType, file }: S3RequestParam) => { - const res = await axios.put(url, file, { - headers: { - 'Content-Type': contentType, - }, - }); - return res; -}; - -export const s3 = ( - imageCategory: string, - file: File, - onSuccess: (url: string) => void -) => { - const contentType = file.type; - const fileName = file.name; - const req = { - contentType: contentType, - fileName: fileName, - }; - - requestPresignedUrl({ - imageCategory: imageCategory, - requests: req, - file: file, - }) - .then(async (data) => { - const { contentType, presignedUrl } = data; - await toS3({ - url: presignedUrl, - contentType: contentType, - file: file, - }); - - return presignedUrl.split('?')[0]; - }) - .then((url) => { - onSuccess(url); - }); -}; diff --git a/src/api/recruitment.ts b/src/api/recruitment.ts index 554df003..c53a62ad 100644 --- a/src/api/recruitment.ts +++ b/src/api/recruitment.ts @@ -6,10 +6,10 @@ import { ServerResponse } from './types'; // 팀 모집글 생성 interface ICreateRecuitmentRequest { - teamId?: string; + teamId?: string | null; teamRecruitTitle: string; teamRecruitBody: string; - image?: string[] | undefined; + image?: File[] | null; chatUrl: string; } @@ -89,15 +89,30 @@ export const createRecruitPost = async ({ teamId, teamRecruitTitle, teamRecruitBody, + image, chatUrl, }: ICreateRecuitmentRequest) => { + const formData = new FormData(); + + if (teamId) { + formData.append('teamId', teamId); + } + formData.append('teamRecruitTitle', teamRecruitTitle); + formData.append('teamRecruitBody', teamRecruitBody); + formData.append('chatUrl', chatUrl); + if (image) { + image.forEach((img) => { + formData.append('image', img); + }); + } + const res = await apiInstance.post>( 'v1/recruitments', + formData, { - teamId, - teamRecruitTitle, - teamRecruitBody, - chatUrl, + headers: { + 'Content-Type': 'multipart/form-data', + }, } ); @@ -107,17 +122,29 @@ export const createRecruitPost = async ({ export const modifyRecruitPost = async ({ teamRecruitTitle, teamRecruitBody, + image, chatUrl, recruitmentId, }: ICreateRecuitmentRequest & { recruitmentId: number; }) => { + const formData = new FormData(); + formData.append('teamRecruitTitle', teamRecruitTitle); + formData.append('teamRecruitBody', teamRecruitBody); + formData.append('chatUrl', chatUrl); + if (image) { + image.forEach((img) => { + formData.append('image', img); + }); + } + const res = await apiInstance.put>( `v1/recruitments/${recruitmentId}`, + formData, { - teamRecruitBody, - teamRecruitTitle, - chatUrl, + headers: { + 'Content-Type': 'multipart/form-data', + }, } ); return res.data.data; diff --git a/src/api/subject.ts b/src/api/subject.ts index 800bd185..1f163d09 100644 --- a/src/api/subject.ts +++ b/src/api/subject.ts @@ -6,22 +6,43 @@ type SubjectMutationArg = { articleBody: string; articleCategory: string; selectedDate: string; + images: File[] | null; }; export const createSubject = async ({ teamId, articleTitle, articleBody, + images, articleCategory, selectedDate, }: SubjectMutationArg) => { - const res = await apiInstance.post(`/v1/articles/teams/${teamId}/subjects`, { - teamId, - articleTitle, - articleBody, - articleCategory, - selectedDate, - }); + const formData = new FormData(); + + formData.append('teamId', teamId.toString()); + formData.append('articleTitle', articleTitle); + formData.append('articleBody', articleBody); + formData.append('articleCategory', articleCategory); + formData.append('selectedDate', selectedDate); + if (images) { + images.forEach((img) => { + // formData.append('images', img); + formData.append('image', img); + }); + } + // else { + // formData.append('images', ''); + // } + + const res = await apiInstance.post( + `/v1/articles/teams/${teamId}/subjects`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); return res.data; }; @@ -32,6 +53,7 @@ type PutSubjectArgs = { articleBody: string; articleCategory: string; articleId: number; + images: File[] | null; }; export const mutateSubject = async ({ @@ -40,13 +62,30 @@ export const mutateSubject = async ({ articleBody, articleCategory, articleId, + images, }: PutSubjectArgs) => { + const formData = new FormData(); + + formData.append('articleTitle', articleTitle); + formData.append('articleBody', articleBody); + formData.append('articleCategory', articleCategory); + if (images) { + images.forEach((img) => { + // formData.append('images', img); + formData.append('image', img); + }); + } + // else { + // formData.append('images', ''); + // } + const res = await apiInstance.put( `/v1/articles/teams/${teamId}/subjects/${articleId}`, + formData, { - articleTitle, - articleBody, - articleCategory, + headers: { + 'Content-Type': 'multipart/form-data', + }, } ); diff --git a/src/api/team.ts b/src/api/team.ts index 15b47eed..0f06e3d9 100644 --- a/src/api/team.ts +++ b/src/api/team.ts @@ -1,11 +1,7 @@ import { isDevMode } from '@/utils/cookie.ts'; import apiInstance from '@/api/apiInstance'; -import { - teamAdminPageMock, - teamCombinedMock, - teamSearchMock, -} from '@/api/mocks.ts'; +import { teamAdminPageMock } from '@/api/mocks.ts'; import { ServerResponse } from '@/api/types'; // 생성 @@ -18,14 +14,14 @@ interface ITeamCommon { interface ICreateTeamRequest extends ITeamCommon { password: string; - image: string | undefined; + image?: File | null; teamMemberUuids?: string[]; - teamRecruitId: number | undefined; + teamRecruitId: number | null; } interface IMPutTeamRequest extends ITeamCommon { teamId: number; - image: string | undefined; + image?: File | null; password: string | null; } @@ -43,28 +39,47 @@ interface ITeamMember { export interface ITeamInfo extends ITeamCommon { teamId: number; - imageUrl: string; + teamName: string; + teamExplain: string; + topic: string; + memberLimit: number; memberCount: number; streakDays: number; - // successMemberCount: number; - teamAnnouncement?: string; + imageUrl: string; createdAt: string; - teamRecruitId: number | null; - // password: string; + teamRecruitId?: number | null; + teamAnnouncement?: string; members?: ITeamMember[]; - memberLimit: number; } interface ITeamListResponse { myTeams: ITeamInfo[]; allTeams: { content: ITeamInfo[]; - page: { - size: number; - number: number; - totalElements: number; - totalPages: number; + empty: boolean; + first: boolean; + last: boolean; + number: number; + numberOfElements: number; + pageable: { + pageNumber: number; + pageSize: number; + offset: number; + sort: { + unsorted: boolean; + sorted: boolean; + empty: boolean; + }; + unpaged: boolean; }; + size: number; + sort: { + unsorted: boolean; + sorted: boolean; + empty: boolean; + }; + totalElements: number; + totalPages: number; }; } @@ -99,20 +114,36 @@ export const createTeam = async ({ teamMemberUuids, teamRecruitId, }: ICreateTeamRequest) => { - const req = { - teamName, - teamExplain, - topic, - memberLimit, - password, - teamIconUrl: image && image?.length > 0 ? image : null, - teamMemberUuids, - teamRecruitId, - }; + const formData = new FormData(); + + formData.append('teamName', teamName); + formData.append('teamExplain', teamExplain); + formData.append('topic', topic); + formData.append('password', password); + formData.append('memberLimit', memberLimit.toString()); + + if (image) { + formData.append('image', image); + } + + if (teamMemberUuids) { + teamMemberUuids.forEach((uuid) => { + formData.append('teamMemberUuids', uuid); + }); + } + + if (teamRecruitId !== null) { + formData.append('teamRecruitId', teamRecruitId.toString()); + } const res = await apiInstance.post>( 'v1/teams', - req + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } ); return res.data.data; @@ -127,18 +158,27 @@ export const modifyTeam = async ({ image, teamId, }: IMPutTeamRequest) => { - const req = { - teamName, - teamExplain, - topic, - memberLimit, - password, - teamIconUrl: image && image?.length > 0 ? image : null, - }; + const formData = new FormData(); + + formData.append('teamName', teamName); + formData.append('teamExplain', teamExplain); + formData.append('topic', topic); + formData.append('memberLimit', memberLimit.toString()); + if (image) { + formData.append('image', image); + } + if (password) { + formData.append('password', password); + } const res = await apiInstance.put>( `v1/teams/${teamId}`, - req + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } ); return res.data.data; @@ -149,10 +189,6 @@ export const getTeamList = async ( page: number = 0, size: number = 6 ): Promise => { - if (isDevMode()) { - return teamCombinedMock.data; - } - const res = await apiInstance.get>( `/v1/teams/combined`, { @@ -182,10 +218,6 @@ export const searchTeams = async ( page: number = 0, size: number = 6 ): Promise => { - if (isDevMode()) { - return teamSearchMock.data; - } - const res = await apiInstance.get>( `/v1/teams/search`, { diff --git a/src/api/user.ts b/src/api/user.ts index 564932b9..93dfc37c 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,10 +1,8 @@ -import { isDevMode } from '@/utils/cookie.ts'; - import apiInstance from '@/api/apiInstance'; -import { membersInfoMock } from '@/api/mocks.ts'; +import { API_BASE_URL } from '@/api/config.ts'; import { ServerResponse } from '@/api/types'; -export const kakaoOauth2LoginUrl = `/oauth2/authorization/kakao`; +export const kakaoOauth2LoginUrl = `${API_BASE_URL}/oauth2/authorization/kakao`; type ProfileCommonArgs = { memberName: string; @@ -12,7 +10,7 @@ type ProfileCommonArgs = { }; type ProfileMutationArgs = ProfileCommonArgs & { - imageUrl?: string; + image: File | null; }; export type ProfileQueryResp = ProfileCommonArgs & { @@ -23,18 +21,21 @@ export type ProfileQueryResp = ProfileCommonArgs & { export const createProfile = async ({ memberName, memberExplain, - imageUrl, + image, }: ProfileMutationArgs) => { - const payload: Record = { - memberName, - memberExplain, - }; + const formData = new FormData(); - if (imageUrl && imageUrl.trim() !== '') { - payload.imageUrl = imageUrl; + formData.append('memberName', memberName); + formData.append('memberExplain', memberExplain); + if (image) { + formData.append('image', image); } - const res = await apiInstance.post('v1/members', payload); + const res = await apiInstance.post('v1/members', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); return res.data; }; @@ -42,18 +43,21 @@ export const createProfile = async ({ export const changeProfile = async ({ memberName, memberExplain, - imageUrl, + image, }: ProfileMutationArgs) => { - const payload: Record = { - memberName, - memberExplain, - }; + const formData = new FormData(); - if (imageUrl && imageUrl.trim() !== '') { - payload.imageUrl = imageUrl; + formData.append('memberName', memberName); + formData.append('memberExplain', memberExplain); + if (image) { + formData.append('image', image); } - const res = await apiInstance.put('v1/members', payload); + const res = await apiInstance.put('v1/members', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); return res.data; }; @@ -93,9 +97,9 @@ type MemberInfoResp = { }; export const getMemberInfo = async () => { - if (isDevMode()) { - return membersInfoMock.data; - } + // if (isDevMode()) { + // return membersInfoMock.data; + // } const res = await apiInstance.get>('v1/members/info'); diff --git a/src/assets/Header/header.png b/src/assets/Header/header.png new file mode 100644 index 00000000..0271ecd4 Binary files /dev/null and b/src/assets/Header/header.png differ diff --git a/src/assets/Home/comonBanner.png b/src/assets/Home/comonBanner.png new file mode 100644 index 00000000..2d3db2de Binary files /dev/null and b/src/assets/Home/comonBanner.png differ diff --git a/src/assets/Home/goalAchievement.svg b/src/assets/Home/goalAchievement.svg new file mode 100644 index 00000000..8a65c2ab --- /dev/null +++ b/src/assets/Home/goalAchievement.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/Home/goalContinually.svg b/src/assets/Home/goalContinually.svg new file mode 100644 index 00000000..61264bfd --- /dev/null +++ b/src/assets/Home/goalContinually.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/Home/goalTogether.svg b/src/assets/Home/goalTogether.svg new file mode 100644 index 00000000..f14c68f1 --- /dev/null +++ b/src/assets/Home/goalTogether.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/Home/instagram.svg b/src/assets/Home/instagram.svg deleted file mode 100644 index 55ddcdde..00000000 --- a/src/assets/Home/instagram.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/Home/name-last.svg b/src/assets/Home/name-last.svg deleted file mode 100644 index 70381789..00000000 --- a/src/assets/Home/name-last.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/Home/notion.svg b/src/assets/Home/notion.svg deleted file mode 100644 index 168f3ce8..00000000 --- a/src/assets/Home/notion.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/Landing/ampersand.png b/src/assets/Landing/ampersand.png new file mode 100644 index 00000000..612bb1f5 Binary files /dev/null and b/src/assets/Landing/ampersand.png differ diff --git a/src/assets/Landing/caret_left.svg b/src/assets/Landing/caret_left.svg new file mode 100644 index 00000000..542cb419 --- /dev/null +++ b/src/assets/Landing/caret_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/Landing/caret_right.svg b/src/assets/Landing/caret_right.svg new file mode 100644 index 00000000..03519511 --- /dev/null +++ b/src/assets/Landing/caret_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/Landing/example_img1.png b/src/assets/Landing/example_img1.png new file mode 100644 index 00000000..8fec29ba Binary files /dev/null and b/src/assets/Landing/example_img1.png differ diff --git a/src/assets/Landing/example_img2.png b/src/assets/Landing/example_img2.png new file mode 100644 index 00000000..f10f390c Binary files /dev/null and b/src/assets/Landing/example_img2.png differ diff --git a/src/assets/Landing/example_img3.png b/src/assets/Landing/example_img3.png new file mode 100644 index 00000000..a614f7dd Binary files /dev/null and b/src/assets/Landing/example_img3.png differ diff --git a/src/assets/Landing/group_icon.svg b/src/assets/Landing/group_icon.svg new file mode 100644 index 00000000..26bf1fb6 --- /dev/null +++ b/src/assets/Landing/group_icon.svg @@ -0,0 +1,64 @@ + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/assets/Landing/half_comon.png b/src/assets/Landing/half_comon.png new file mode 100644 index 00000000..84522abc Binary files /dev/null and b/src/assets/Landing/half_comon.png differ diff --git a/src/assets/Landing/logo_box.png b/src/assets/Landing/logo_box.png new file mode 100644 index 00000000..e9702a47 Binary files /dev/null and b/src/assets/Landing/logo_box.png differ diff --git a/src/assets/Landing/manage_icon.svg b/src/assets/Landing/manage_icon.svg new file mode 100644 index 00000000..72ed214b --- /dev/null +++ b/src/assets/Landing/manage_icon.svg @@ -0,0 +1,33 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/assets/Landing/record_icon.svg b/src/assets/Landing/record_icon.svg new file mode 100644 index 00000000..4cd5682b --- /dev/null +++ b/src/assets/Landing/record_icon.svg @@ -0,0 +1,38 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/assets/Landing/right_arrow.png b/src/assets/Landing/right_arrow.png new file mode 100644 index 00000000..437d9516 Binary files /dev/null and b/src/assets/Landing/right_arrow.png differ diff --git a/src/assets/Landing/star_text.png b/src/assets/Landing/star_text.png new file mode 100644 index 00000000..9c7c1f4b Binary files /dev/null and b/src/assets/Landing/star_text.png differ diff --git a/src/assets/Landing/twisted_1.png b/src/assets/Landing/twisted_1.png new file mode 100644 index 00000000..516ac0ff Binary files /dev/null and b/src/assets/Landing/twisted_1.png differ diff --git a/src/assets/Landing/twisted_2.png b/src/assets/Landing/twisted_2.png new file mode 100644 index 00000000..a34e25a1 Binary files /dev/null and b/src/assets/Landing/twisted_2.png differ diff --git a/src/assets/Landing/twisted_3.png b/src/assets/Landing/twisted_3.png new file mode 100644 index 00000000..4a7a1b60 Binary files /dev/null and b/src/assets/Landing/twisted_3.png differ diff --git a/src/assets/Landing/twisted_4.png b/src/assets/Landing/twisted_4.png new file mode 100644 index 00000000..350aee09 Binary files /dev/null and b/src/assets/Landing/twisted_4.png differ diff --git a/src/assets/Landing/twisted_5.png b/src/assets/Landing/twisted_5.png new file mode 100644 index 00000000..3db72c8b Binary files /dev/null and b/src/assets/Landing/twisted_5.png differ diff --git a/src/assets/Landing/twisted_6.png b/src/assets/Landing/twisted_6.png new file mode 100644 index 00000000..c05e5708 Binary files /dev/null and b/src/assets/Landing/twisted_6.png differ diff --git a/src/assets/TeamDashboard/lock.png b/src/assets/TeamDashboard/lock.png new file mode 100644 index 00000000..63f8ec12 Binary files /dev/null and b/src/assets/TeamDashboard/lock.png differ diff --git a/src/assets/TeamDashboard/lock.svg b/src/assets/TeamDashboard/lock.svg deleted file mode 100644 index 0efb04be..00000000 --- a/src/assets/TeamDashboard/lock.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/TeamDashboard/message_circle.png b/src/assets/TeamDashboard/message_circle.png new file mode 100644 index 00000000..0be8934b Binary files /dev/null and b/src/assets/TeamDashboard/message_circle.png differ diff --git a/src/assets/TeamDashboard/message_circle.svg b/src/assets/TeamDashboard/message_circle.svg deleted file mode 100644 index b31b266c..00000000 --- a/src/assets/TeamDashboard/message_circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/commons/Container.tsx b/src/components/commons/Container.tsx index a78931a2..66370b8a 100644 --- a/src/components/commons/Container.tsx +++ b/src/components/commons/Container.tsx @@ -4,7 +4,7 @@ import { breakpoints } from '@/constants/breakpoints'; import styled from '@emotion/styled'; interface ContainerProps { - maxW?: number | string; + maxW?: number; padding?: string; scrollSnapType?: string; scrollSnapAlign?: string; @@ -15,12 +15,7 @@ interface ContainerProps { export const ContainerStyle = styled.div` width: 100%; - max-width: ${(props) => { - if (props.maxW !== undefined) { - return typeof props.maxW === 'number' ? `${props.maxW}px` : props.maxW; - } - return '1300px'; - }}; + max-width: ${(props) => props.maxW || 1300}px; padding: ${(props) => props.padding || '0'}; margin: ${(props) => props.margin || '0 auto'}; ${(props) => diff --git a/src/components/commons/Form/ComonImageInput.tsx b/src/components/commons/Form/ComonImageInput.tsx index 5ed9a88b..0bb326e5 100644 --- a/src/components/commons/Form/ComonImageInput.tsx +++ b/src/components/commons/Form/ComonImageInput.tsx @@ -5,9 +5,8 @@ import { SText } from '@/components/commons/SText'; import { SimpleLoader } from '@/components/commons/SimpleLoader'; import { HeightInNumber } from '@/components/types'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { s3 } from '@/api/presignedurl.ts'; import { breakpoints } from '@/constants/breakpoints'; import { MAX_IMAGE_SIZE, imageAtom, isImageFitAtom } from '@/store/form'; import styled from '@emotion/styled'; @@ -129,23 +128,50 @@ export const ComonImageInput: React.FC<{ isDisabled?: boolean; h?: number; padding?: string; - imageCategory: string; -}> = ({ - imageCategory, - imageUrl, - isDisabled, - h = 200, - padding = '5px 14px', -}) => { +}> = ({ imageUrl, isDisabled, h = 200, padding = '5px 14px' }) => { const [image, setImage] = useAtom(imageAtom); const [imageStr, setImageStr] = useState(imageUrl ?? null); + const workerRef = useRef(null); + + const loadCompressedImage = useCallback( + (file: File) => { + if (!workerRef.current) { + workerRef.current = new Worker( + new URL('@/workers/imageCompressor.ts', import.meta.url), + { type: 'module' } + ); + + workerRef.current.onmessage = (e) => { + const { compressedImage, error = undefined } = e.data; + if (compressedImage && !error) { + setImage(compressedImage); + return; + } + console.error('이미지 압축에 실패했습니다.', error); + setImage(file); + }; + } + + const reader = new FileReader(); + + reader.onload = (e: ProgressEvent) => { + const worker = workerRef.current; + worker?.postMessage({ + src: e?.target?.result, + fileType: file.type, + fileName: '설정할 파일 이름', + quality: 0.8, + }); + }; + reader.readAsDataURL(file); + }, + [setImage] + ); const handleImageChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { const file = e.target.files[0]; - s3(imageCategory, file, (url: string) => { - setImage(url); - }); + loadCompressedImage(file); } }; @@ -153,19 +179,21 @@ export const ComonImageInput: React.FC<{ e.preventDefault(); if (!isDisabled && e.dataTransfer.files && e.dataTransfer.files[0]) { const file = e.dataTransfer.files[0]; - s3(imageCategory, file, (url: string) => { - setImage(url); - }); + loadCompressedImage(file); } }; - useEffect(() => { - setImageStr(image); - }, [image]); - const handleDragOver = (e: React.DragEvent) => e.preventDefault(); + useEffect(() => { + if (image) { + const reader = new FileReader(); + reader.onload = () => setImageStr(reader.result as string); + reader.readAsDataURL(image); + } + }, [image]); + const fontSize = h === 80 ? '12px' : '14px'; const width = useWindowWidth(); diff --git a/src/components/commons/Header.tsx b/src/components/commons/Header.tsx index 2714b321..7cf10b85 100644 --- a/src/components/commons/Header.tsx +++ b/src/components/commons/Header.tsx @@ -1,4 +1,3 @@ -import { checkRemainingCookies, isDevMode } from '@/utils/cookie'; import { useWindowWidth } from '@/hooks/useWindowWidth.ts'; @@ -10,12 +9,14 @@ import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { logout } from '@/api/user'; -import logo from '@/assets/Header/logo.svg'; +import logo from '@/assets/Header/header.png'; import user from '@/assets/Header/user.svg'; import { breakpoints } from '@/constants/breakpoints'; import { colors } from '@/constants/colors'; import { PATH } from '@/routes/path'; +import { isLoggedInAtom } from '@/store/auth'; import styled from '@emotion/styled'; +import { useAtom } from 'jotai'; const Blur = styled.div` width: 100%; @@ -119,8 +120,7 @@ const ComonLogoWrap = styled.div` `; const LogoImage = styled.img` - height: 24px; - + height: 18.5px; @media (max-width: ${breakpoints.mobile}px) { transform: translateX(-84px); } @@ -155,9 +155,10 @@ type ModalControl = { }; export const Header: React.FC = ({ h }) => { - const [isLoggedIn] = useState( - checkRemainingCookies() || isDevMode() - ); + // const [isLoggedIn] = useState( + // checkRemainingCookies() || isDevMode() + // ); + const [isLoggedIn] = useAtom(isLoggedInAtom); const [open, setOpen] = useState(false); const containerRef = useRef(null); const modalControlRef = useRef({ @@ -235,9 +236,10 @@ export const Header: React.FC = ({ h }) => { { const { message, isVisible, onConfirm } = useAtomValue(alertAtom); @@ -12,7 +10,7 @@ export const Alert: React.FC = () => { const onClick = () => { onConfirm(); setModal({ message: '', isVisible: false, onConfirm: () => {} }); - }; + } return ( {}}> @@ -22,17 +20,13 @@ export const Alert: React.FC = () => { 확인 ); -}; +} const AlertStyle = styled.div` font-size: 18px; font-weight: 600; margin: 50px auto; color: #333; - - @media (max-width: ${breakpoints.mobile}px) { - font-size: 12px; - } `; const AlertButton = styled.button` @@ -41,14 +35,8 @@ const AlertButton = styled.button` right: 25px; width: 87px; height: 38px; - background-color: #8488ec; + background-color: #8488EC; color: #fff; border-radius: 40px; font-size: 16px; - - @media (max-width: ${breakpoints.mobile}px) { - font-size: 12px; - width: 60px; - height: 30px; - } -`; +`; \ No newline at end of file diff --git a/src/components/commons/Modal/Confirm.tsx b/src/components/commons/Modal/Confirm.tsx index 1f4cd8d1..a1964376 100644 --- a/src/components/commons/Modal/Confirm.tsx +++ b/src/components/commons/Modal/Confirm.tsx @@ -9,15 +9,7 @@ export const Confirm: React.FC = () => { // const { message, description, isVisible, onConfirm, onCancel } = useAtomValue(confirmAtom); // const setModal = useSetAtom(confirmAtom); const [modal, setModal] = useAtom(confirmAtom); - const { - message, - description, - isVisible, - cancelText = '취소', - confirmText = '나가기', - onConfirm, - onCancel, - } = modal; + const { message, description, isVisible, cancleText = '취소', confirmText = '나가기', onConfirm, onCancel } = modal; const queryClient = useQueryClient(); @@ -51,7 +43,7 @@ export const Confirm: React.FC = () => { {message} {description} - {cancelText} + {cancleText} {confirmText} diff --git a/src/components/commons/SText.tsx b/src/components/commons/SText.tsx index a88b09c3..53af3ba5 100644 --- a/src/components/commons/SText.tsx +++ b/src/components/commons/SText.tsx @@ -30,7 +30,7 @@ export const SText = styled.div` word-break: ${(props) => props.wordBreak || 'inherit'}; opacity: ${(props) => props.opacity || 'inherit'}; cursor: ${(props) => props.cursor || 'inherit'}; - font-style: ${(props) => props.fontStyle || 'normal'}; + font-style: ${(props) => props.fontStyle || 'inherit'}; text-decoration: ${(props) => props.textDecoration || 'inherit'}; ${(props) => diff --git a/src/components/features/Home/QnAList.tsx b/src/components/features/Home/QnAList.tsx index 1032b0bb..f87d9e1e 100644 --- a/src/components/features/Home/QnAList.tsx +++ b/src/components/features/Home/QnAList.tsx @@ -3,6 +3,8 @@ import { SText } from '@/components/commons/SText'; import { useState } from 'react'; import arrowDownIcon from '@/assets/Home/arrow-faq.svg'; +import { breakpoints } from '@/constants/breakpoints'; +import { useWindowWidth } from '@/hooks/useWindowWidth'; import styled from '@emotion/styled'; export const QnAList = () => { @@ -16,7 +18,7 @@ export const QnAList = () => { { question: '코드몬스터에서 자체적으로 코딩테스트 문제를 제공하나요?', answer: - '아쉽게도 서비스 자체적으로 코딩테스트 문제를 제공해 드리진 않고 있어요. 하지만 현재 코몬 팀에서 직접 스터디를 운영하며, 일주일에 많게는 6일까지 문제를 올려드리고 풀이를 공유하고 있어요. 약 100명의 팀원과 함께 코테 풀이를 시작해 보아요!', + '아쉽게도 서비스 자체적으로 코딩테스트 문제를 제공해 드리진 않고 있어요. 하지만 현재 코몬 팀에서 직접 스터디를 운영하며, 일주일에 많게는 6일까지 문제를 올려드리고 풀이를 공유하고 있어요. 약 200명의 팀원과 함께 코테 풀이를 시작해 보아요!', }, { question: '팀원 없이 혼자 코테 문제를 풀고 풀이를 작성해도 되나요?', @@ -40,34 +42,49 @@ export const QnAList = () => { }, ]; + const isMobile = useWindowWidth() < breakpoints.mobile; + return ( -
+
{qnaData.map((item, index) => ( toggle(index)}>
+
+ + Q. + - Q. {item.question} + {item.question} +
{openIndex === index ? ( 접기 ) : ( - 펼치기 + 펼치기 )}
- A. {item.answer} + A.{item.answer}
))}
@@ -82,6 +99,11 @@ const ItemWrapper = styled.div` cursor: pointer; transition: all 0.2s ease-in-out; margin-bottom: 20px; + + @media (max-width: ${breakpoints.mobile}px) { + padding: 10px 12px 10px 20px; + margin-bottom: 10px; + } `; const Header = styled.div` @@ -95,11 +117,13 @@ const Header = styled.div` `; const Answer = styled.div<{ isOpen: boolean }>` + display: flex; + gap: 12px; padding-right: 30px; font-size: 18px; - color: #333; + color: #767676; font-weight: 500; - line-height: 140%; + line-height: 150%; letter-spacing: -0.18px; max-height: ${({ isOpen }) => (isOpen ? '100px' : '0')}; overflow: hidden; @@ -109,4 +133,11 @@ const Answer = styled.div<{ isOpen: boolean }>` max-height 0.3s ease, opacity 0.3s ease, margin-top 0.3s ease; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 9px; + line-height: 140%; + letter-spacing: -0.12px; + gap: 6px; + } `; diff --git a/src/components/features/Landing/AnimatedImages.tsx b/src/components/features/Landing/AnimatedImages.tsx new file mode 100644 index 00000000..d7f80130 --- /dev/null +++ b/src/components/features/Landing/AnimatedImages.tsx @@ -0,0 +1,356 @@ +import BackLeft from '@/assets/Landing/ampersand.png'; +import LogoBox from "@/assets/Landing/logo_box.png"; +import StarText from "@/assets/Landing/star_text.png"; +import FrontLeft from '@/assets/Landing/twisted_4.png'; +import FrontRight from '@/assets/Landing/twisted_5.png'; +import BackRight from '@/assets/Landing/twisted_6.png'; +import { breakpoints } from '@/constants/breakpoints'; +import { useWindowWidth } from '@/hooks/useWindowWidth'; +import styled from '@emotion/styled'; +import { useEffect, useRef, useState } from 'react'; + +const BackgroundGroup = () => { + const [ratio, setRatio] = useState(0); + const wrapperRef = useRef(null); + const [direction, setDirection] = useState<'up' | 'down' | null>(null); + const [isAnimating, setIsAnimating] = useState(false); + const isMobile = useWindowWidth() < breakpoints.mobile; + + const isInViewport = (el: HTMLElement) => { + const rect = el.getBoundingClientRect(); + return rect.top < window.innerHeight && rect.bottom > 0; + }; + + useEffect(() => { + const preventScroll = (e: Event) => { + e.preventDefault(); + }; + + if (isAnimating && !isMobile) { + window.addEventListener('wheel', preventScroll, { passive: false }); + window.addEventListener('touchmove', preventScroll, { passive: false }); + } else { + window.removeEventListener('wheel', preventScroll); + window.removeEventListener('touchmove', preventScroll); + } + + return () => { + window.removeEventListener('wheel', preventScroll); + window.removeEventListener('touchmove', preventScroll); + }; + }, [isAnimating, isMobile]); + + + + const onWheel = (e: WheelEvent) => { + if (isMobile) return; + if (!wrapperRef.current || !isInViewport(wrapperRef.current)) return; + if (isAnimating) { + e.preventDefault(); + return; + } + if (e.deltaY > 0 && ratio === 0) { + e.preventDefault(); + setDirection('down'); + } + if (e.deltaY < 0 && ratio === 1) { + e.preventDefault(); + setDirection('up'); + } + }; + + useEffect(() => { + if (isMobile) return; + window.addEventListener('wheel', onWheel, { passive: false }); + return () => window.removeEventListener('wheel', onWheel); + }, [isAnimating, ratio, isMobile]); + + useEffect(() => { + if (!direction) return; + + setIsAnimating(true); + let start: number | null = null; + const from = ratio; + const to = direction === 'down' ? 1 : 0; + const duration = 1000; + + const animate = (t: number) => { + if (start === null) start = t; + const elapsed = t - start; + const progress = Math.min(elapsed / duration, 1); + const next = from + (to - from) * progress; + + setRatio(next); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setIsAnimating(false); + setDirection(null); + } + }; + + requestAnimationFrame(animate); + }, [direction]); + + + return ( + + + + + + + <span style={{ color: "#6E74FA" }}>코딩테스트</span> 준비해야 하지 않나요? + <span style={{color: "#F15CA7"}}>팀</span>을 찾고 꾸준히 풀어보세요 + + + + +
+ + const prepareCodingTest = () => { +     console.log("팀을 찾고 꾸준히 풀어보세요!"); +     return "코드몬스터"; + }; +   + prepareCodingTest(); // 준비 시작! + +
+
+ + + + +
+
+ + + + +
+ ); +}; + +export default BackgroundGroup; + +const Wrapper = styled.div` + position: relative; + width: 100%; + height: 300px; + overflow: hidden; + + @media (max-width: ${breakpoints.mobile}px) { + height: 150px; + overflow: visible; + } +`; + +const ContentWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + `; + + const FadeContent = styled.div` + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: opacity 0.5s ease; + pointer-events: none; + + @media (max-width: ${breakpoints.mobile}px) { + display: flex; + flex-direction: row; + height: fit-content; + align-items: center; + justify-content: center; + } +`; + +const AnimatedImage = styled.img<{ ratio: number }>` + position: absolute; + will-change: transform, filter, opacity; +`; + +const Image1 = styled(AnimatedImage)` + bottom: 0; + right: 0; + width: 144px; + height: 144px; + transform: ${({ ratio }) => + ratio + ? 'translateX(0) translateY(-62px) scale(0.8)' + : 'translateX(calc(-50vw)) translateY(0) scale(1)'}; + filter: ${({ ratio }) => (ratio ? 'blur(0)' : 'blur(0)')}; + opacity: ${({ ratio }) => (ratio ? 1 : 1)}; + transition: + transform 1s ease-in-out, + filter 1s ease-in-out 0.5s, + opacity 1s ease-in-out 0.5s; + z-index: 2; + + @media (max-width: ${breakpoints.mobile}px) { + position: absolute; + width: 50px; + height: 50px; + filter: blur(0) !important; + opacity: 1 !important; + left: 50vw; + top: 50px; + } +`; + +const Image2 = styled(AnimatedImage)` + bottom: 62px; + right: 0; + width: 137px; + height: 137px; + transform: ${({ ratio }) => + ratio ? 'translate(-668px, -106px) scale(0.75)' : 'translate(0, 0) scale(1)'}; + filter: ${({ ratio }) => (ratio ? 'blur(4px)' : 'blur(0)')}; + opacity: ${({ ratio }) => (ratio ? 0.8 : 1)}; + transition: + transform 1s ease-in-out, + filter 1s ease-in-out 0.5s, + opacity 1s ease-in-out 0.5s; + + @media (max-width: ${breakpoints.mobile}px) { + width: 0px; + height: 0px; + filter: blur(0) !important; + opacity: 1 !important; + } +`; + +const Image3 = styled(AnimatedImage)` + top: 0; + left: 145px; + width: 162px; + height: 182px; + transform: ${({ ratio }) => + ratio ? 'translate(-135px, 140px) scale(1)' : 'translate(0, 0) scale(0.75)'}; + filter: ${({ ratio }) => (ratio ? 'blur(0)' : 'blur(4px)')}; + opacity: ${({ ratio }) => (ratio ? 1 : 0.8)}; + transition: + transform 1s ease-in-out, + filter 1s ease-in-out 0.5s, + opacity 1s ease-in-out 0.5s; + + @media (max-width: ${breakpoints.mobile}px) { + left: 30px; + top: -15px; + width: 48px; + height: 52px; + filter: blur(3px) !important; + opacity: 1 !important; + } +`; + +const Image4 = styled.img` + position: absolute; + top: 128px; + right: 260px; + width: 98px; + height: 98px; + z-index: 1; + + @media (max-width: ${breakpoints.mobile}px) { + top: 44px; + right: 16px; + width: 35px; + height: 35px; + opacity: 1 !important; + } +`; + +const InteractionContainer = styled.div` + display: flex; + flex-direction: column; + height: 306px; + z-index: 10; + align-items: center; + justify-content: center; +`; + +const ImgContainer = styled.div` + position: absolute; + left: 50%; + transform: translateX(calc(-50% - 420px)); + top: 48px; + display: flex; + gap: 3px; + align-items: flex-start; + z-index: 10; + + @media (max-width: ${breakpoints.mobile}px) { + transform: none; + left: -5px; + top: 25px; + height: fit-content; + } +`; + +const StarTextImg = styled.img` + width: 45px; + height: 20px; + margin-top: 5px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 18px; + height: 8px; + margin-top: 0; + } +`; + +const LogoBoxImg = styled.img` + width: 70px; + height: 40px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 26px; + height: 16px; + } +`; + +const TextContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + height: 228px; + + @media (max-width: ${breakpoints.mobile}px) { + height: 100px; + justify-content: center; + margin-left: 42px; + } +`; + +const Title = styled.div` + font-size: 50px; + font-weight: 700; + line-height: 1.4; + color: #212529; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 24px; + } +`; + +const CodeText = styled.span` + color: #FF65B2; + font-family: 'MoneygraphyPixel'; + font-size: 32px; + font-style: normal; + font-weight: 400; + line-height: 38px; + letter-spacing: 0.32px; +`; \ No newline at end of file diff --git a/src/components/features/Landing/AnimatedLanding.tsx b/src/components/features/Landing/AnimatedLanding.tsx new file mode 100644 index 00000000..f56146db --- /dev/null +++ b/src/components/features/Landing/AnimatedLanding.tsx @@ -0,0 +1,141 @@ +import RightArrowIcon from "@/assets/Landing/right_arrow.png"; +import { Spacer } from "@/components/commons/Spacer"; +import { breakpoints } from "@/constants/breakpoints"; +import { useWindowWidth } from "@/hooks/useWindowWidth"; +import { PATH } from "@/routes/path"; +import styled from "@emotion/styled"; +import { useNavigate } from "react-router-dom"; +import AnimatedImages from "./AnimatedImages"; + +export const AnimatedLanding = () => { + + const navigate = useNavigate(); + const isMobile = useWindowWidth() < breakpoints.mobile; + + const handleClick = () => { + navigate(`${PATH.TEAM_RECRUIT}/list`); + } + return ( + + + + +
+ 이미 약 200명의 개발자가 + 코몬에서 팀원들과 풀이를 공유하고 있어요. +
+
+ 우리 모두 꾸준히 해야한다는 걸 알면서 + 조금씩 미루곤 하는 코테 준비. +
+
+ 코몬에서 알맞은 팀을 찾거나, 혹은 새로 팀을 만들어 + 목표를 향해 달려가 보는 건 어떨까요? +
+
+ 함께 가면 더 멀리 갈 수 있으니까요! +
+ +
+
+ ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + align-items: flex-start; + justify-content: flex-start; + padding: 0 195px; + box-sizing: border-box; + + @media (max-width: ${breakpoints.mobile}px) { + padding: 0 36px; + } +`; + + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + position: relative; + +`; + +const Content = styled.div` + display: flex; + font-size: 18px; + line-height: 1.4; + color: #333; + font-weight: 500; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 12px; + font-family: NanumSquareNeo; + } +`; + +const Button = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + + position: absolute; + top: 0; + right: 0; + height: 74px; + box-sizing: border-box; + + padding: 20px 28px; + font-size: 20px; + font-weight: bold; + color: white; + background: #333; + border-radius: 9999px; + + width: 180px; + transition: width 0.3s ease, background 0.3s ease; + overflow: hidden; + cursor: pointer; + + .icon { + opacity: 0; + transform: translateX(-8px); + transition: all 0.3s ease; + width: 0; + height: 0; + } + + &:hover { + width: 220px; + + .icon { + opacity: 1; + transform: translateX(0); + width: 24px; + height: 24px; + } + } + + @media (max-width: ${breakpoints.mobile}px) { + width: 105px; + height: 42px; + position: relative; + font-size: 14px; + font-weight: 500; + margin-top: 45px; + align-self: center; + padding: 10px 12px; + } +`; + +const Icon = styled.img` + display: inline-flex; +`; diff --git a/src/components/features/Landing/Banner.tsx b/src/components/features/Landing/Banner.tsx new file mode 100644 index 00000000..d5d863b8 --- /dev/null +++ b/src/components/features/Landing/Banner.tsx @@ -0,0 +1,85 @@ +import { Spacer } from "@/components/commons/Spacer"; +import { breakpoints } from "@/constants/breakpoints"; +import styled from "@emotion/styled"; + +interface BannerProps { + title?: string; + description1?: string; + description2?: string; + src?: string; +}; + +export const Banner = ({title, description1, description2, src}: BannerProps) => { + return ( + + + + {title} + +
+ {description1} + {description2} +
+
+ ); +} + +const Wrapper = styled.div` + display: flex; + width: 350px; + height: 204px; + padding: 33px 0; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 20px; + background: #FFF; + box-shadow: 2px 2px 20px 0px rgba(94, 96, 153, 0.20); + box-sizing: border-box; + + @media (max-width: ${breakpoints.mobile}px) { + height: 124px; + gap: 5px; + } +`; + +const Icon = styled.img` + width: 45px; + height: 45px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 24px; + height: 24px; + } +`; + +const Title = styled.div` + color: #333; + text-align: center; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 19px; + letter-spacing: -0.32px; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 14px; + line-height: 20px; + } +`; + +const Description = styled.div` + color: #767676; + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 19px; + letter-spacing: -0.28px; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 12px; + line-height: 18px; + } +`; \ No newline at end of file diff --git a/src/components/features/Landing/FeatureCard.tsx b/src/components/features/Landing/FeatureCard.tsx new file mode 100644 index 00000000..16beb7e5 --- /dev/null +++ b/src/components/features/Landing/FeatureCard.tsx @@ -0,0 +1,92 @@ +import { Spacer } from "@/components/commons/Spacer"; +import styled from "@emotion/styled"; + +interface FeatureCardProps { + title: string; + content: string; + tag1: string; + tag2: string; +}; + +export const FeatureCard = ({title, content, tag1, tag2}: FeatureCardProps) => { + return ( + + + + {title} + + {content} + + + {tag1} + {tag2} + + + + + ); +} + +const CardWrapper = styled.div` + background: linear-gradient(235.72deg, #848484 11.56%, #1E1E1E 88.44%); + border-radius: 20px; + padding: 0.5px; + width: 316px; + height: 204px; +`; + +const CardBackground = styled.div` + width: 316px; + height: 204px; + background: #000; + border-radius: 20px; +`; + + +const CardContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + background: linear-gradient(67.79deg, rgba(38, 38, 38, 0.4) 78.95%, rgba(110, 116, 250, 0.4) 99.25%); + border-radius: 20px; + box-shadow: 2px 2px 20px 0px #5E609933; + box-sizing: border-box; + padding: 36px; + width: 316px; + height: 204px; +`; + +const CardTitle = styled.div` + font-size: 18px; + line-height: 19px; + font-weight: 600; + color: #fff; +`; + +const CardContent = styled.div` + font-size: 14px; + line-height: 19px; + font-weight: 500; + color: #A7A7A7; +`; + +const CardTagContainer = styled.div` + display: flex; + gap: 10px; +`; + +const CardTag = styled.div` + font-size: 12px; + color: #fff; + width: 70px; + height: 24px; + background-color: #434697; + border-radius: 40px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +`; + +export default FeatureCard; \ No newline at end of file diff --git a/src/components/features/Landing/ServiceStrength.tsx b/src/components/features/Landing/ServiceStrength.tsx new file mode 100644 index 00000000..2015d13f --- /dev/null +++ b/src/components/features/Landing/ServiceStrength.tsx @@ -0,0 +1,95 @@ +import HalfComonImage from "@/assets/Landing/half_comon.png"; +import { Spacer } from "@/components/commons/Spacer"; +import FeatureCard from "@/components/features/Landing/FeatureCard"; +import { breakpoints } from "@/constants/breakpoints"; +import { useWindowWidth } from "@/hooks/useWindowWidth"; +import styled from "@emotion/styled"; + +const content = [ + { + title: "손쉬운 관리, 한 눈에 보는 기록", + content: "내가 푼 코딩테스트 문제들을 캘린더에서 쉽게 찾아보고, 풀이 과정을 회고 하며 체계적으로 관리할 수 있습니다", + tag1: "팀 캘린더", + tag2: "마이페이지" + }, + { + title: "편한 코드 입력, 코드블럭", + content: "자동 들여쓰기와 문법 강조 기능이 지원되는 코드블럭으로, 복잡한 풀이도 깔끔하게 기록하고 공유할 수 있습니다", + tag1: "코드블럭", + tag2: "오늘의풀이" + }, + { + title: "함께하는 성장, 팀으로 함께", + content: "목표가 같은 동료들과 팀을 이루어 서로의 성장을 돕고, 풀이를 비교 분석하며 더 빠르게 성장해보세요.", + tag1: "팀 페이지", + tag2: "팀모집" + } +]; + + +export const ServiceStrength = () => { + const isMobile = useWindowWidth() < breakpoints.mobile; + return ( + + + "코드몬스터만의 강점" + + + {content.map((item, index) => ( + + ))} + + + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +const Title = styled.div` + font-size: 42px; + font-weight: 700; + color: #fff; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 21px; + } +`; + +const CardContainer = styled.div` + display: flex; + gap: 27px; + margin-bottom: 200px; + + @media (max-width: ${breakpoints.mobile}px) { + flex-direction: column; + margin-bottom: 60px; + } +`; + +const HalfComonImg = styled.img` + width: 411px; + height: auto; + margin-top: 20px; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + + @media (max-width: ${breakpoints.mobile}px) { + width: 200px; + } +`; + +export default ServiceStrength; \ No newline at end of file diff --git a/src/components/features/Landing/UsageExample.tsx b/src/components/features/Landing/UsageExample.tsx new file mode 100644 index 00000000..9329aae4 --- /dev/null +++ b/src/components/features/Landing/UsageExample.tsx @@ -0,0 +1,303 @@ +import CaretLeftIcon from '@/assets/Landing/caret_left.svg'; +import CaretRightIcon from '@/assets/Landing/caret_right.svg'; +import exampleImage1 from '@/assets/Landing/example_img1.png'; +import exampleImage2 from '@/assets/Landing/example_img2.png'; +import exampleImage3 from '@/assets/Landing/example_img3.png'; +import Twisted1 from '@/assets/Landing/twisted_1.png'; +import Twisted2 from '@/assets/Landing/twisted_2.png'; +import Twisted3 from '@/assets/Landing/twisted_3.png'; +import { Spacer } from '@/components/commons/Spacer'; +import { breakpoints } from '@/constants/breakpoints'; +import { useWindowWidth } from '@/hooks/useWindowWidth'; +import styled from '@emotion/styled'; +import { useRef } from 'react'; +import Slider from 'react-slick'; +import "slick-carousel/slick/slick-theme.css"; +import "slick-carousel/slick/slick.css"; + +const usageExamples = (isMobile: boolean) => [ + { + title: '코테 기록을 더 쉽게! ✨', + content: isMobile ? [ + '오늘의 문제 풀이에서 바로 문제를 펼쳐보고 작성해보세요!', + '기본적인 마크다운 단축키와 코드블록으로', + '깔끔한 입력을 지원합니다 ✨' + ] : [ + '오늘의 문제 풀이에서 바로 문제를 펼쳐보고 작성해보세요!', + '기본적인 마크다운 단축키와 코드블록으로 깔끔한 입력을 지원합니다 ✨', + ], + image: exampleImage1, + style: { width: '476px', height: '357px' }, + mobileStyle: { width: '238px', height: '178px' }, + }, + { + title: '꾸준한 학습습관 만들기🏃', + content: isMobile ? [ + '날마다 팀원들과 함께 활동을 기록하세요!', + '캘린더를 통해 문제를 확인하고,', + '하루에 한 문제를 함께 풀어보아요✨' + ] : [ + '날마다 팀원들과 함께 활동을 기록하세요!', + '캘린더를 통해 문제를 확인하고, 하루에 한 문제를 함께 풀어보아요✨', + ], + image: exampleImage2, + style: { width: '944px', height: '357px' }, + mobileStyle: { width: '280px', height: '160px' }, + }, + { + title: '나에게 맞는 팀을 찾아보세요!', + content: [ + '나의 코드테스트 목표와 레벨에 맞는 팀을 찾아보세요!', + '원하는 팀에 참여하거나 직접 팀을 꾸려 함께 성장해봐요✨', + ], + image: exampleImage3, + style: { width: '680px', height: '357px' }, + mobileStyle: { width: '280px', height: '160px' }, + }, +]; + +const UsageExampleCard = ({ index }: { index: number }) => { + const isMobile = useWindowWidth() < breakpoints.mobile; + const { title, content, image, style, mobileStyle } = usageExamples(isMobile)[index]; + + return ( + + {title} + + + + {content[0]} + {content[1]} + { content[2] && {content[2]} } + + + example + + + + + + ); +}; + +export const UsageExample = () => { + const sliderRef = useRef(null); + const isMobile = useWindowWidth() < breakpoints.mobile; + + return ( + +
+ + + + + +
+ + sliderRef.current?.slickPrev()}> + + + sliderRef.current?.slickNext()}> + + +
+ ); +}; + +const StyledSlider = styled(Slider)` + width: 100%; + .slick-slide, + .slick-track { + width: 950px; + height: 600px; +} + .slick-list { + background: transparent !important; + box-shadow: none !important; + border: none !important; + } + + @media (max-width: ${breakpoints.mobile}px) { + .slick-slide, + .slick-track { + width: 340px; + height: 400px; + } + } +`; + +const SliderWrapper = styled.div` + position: relative; + display: flex; + justify-content: center; + align-items: flex-start; +`; + +const NavButton = styled.div` + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 56px; + height: 56px; + border-radius: 100px; + background-color: #00000029; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + @media (max-width: ${breakpoints.mobile}px) { + transform: translateY(-100%); + width: 28px; + height: 28px; + } +`; + +const CaretIcon = styled.img` + width: 24px; + height: 24px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 12px; + height: 12px; + } +`; + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + margin-top: 64px; + width: 100%; + height: 100%; + + @media (max-width: ${breakpoints.mobile}px) { + margin-top: 48px; + } +`; + +const MainBox = styled.div` + display: flex; + flex-direction: column; + width: 900px; + height: 518px; + background-color: #FFFFFF66; + box-shadow: 2px 2px 20px 0px #5E609933; + border-radius: 40px; + border: 3px solid #FFFFFF; + justify-content: center; + align-items: center; + + @media (max-width: ${breakpoints.mobile}px) { + width: 314px; + height: 288px; + justify-content: flex-start; + padding-top: 30px; + box-sizing: border-box; + z-index: 1; + border: 1.5px solid #FFFFFF; + border-radius: 20px; + } +`; + +const TitleBox = styled.div` + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + width: 376px; + height: 68px; + border-radius: 100px; + box-shadow: 0px 4px 10px 0px #0000000A; + background: #FCFCFF; + z-index: 2; + color: #111111; + font-size: 28px; + font-weight: 600; + justify-content: center; + align-items: center; + display: flex; + + @media (max-width: ${breakpoints.mobile}px) { + width: 188px; + height: 34px; + top: -20px; + font-size: 16px; + } +`; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +const ContentText = styled.div` + font-size: 18px; + color: #111; + font-weight: 400; + line-height: 1.4; + text-align: center; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 12px; + } +`; + +const TwistedDecoration1 = styled.img` + position: absolute; + top: 31px; + left: 96px; + width: 74px; + height: 74px; + + @media (max-width: ${breakpoints.mobile}px) { + top: -30px; + left: 0px; + width: 62px; + height: 62px; + } +`; + +const TwistedDecoration2 = styled.img` + position: absolute; + top: 31px; + right: 88px; + width: 72px; + height: 72px; + + @media (max-width: ${breakpoints.mobile}px) { + top: -30px; + right: 0px; + width: 62px; + height: 62px; + } +`; + +const TwistedDecoration3 = styled.img` + position: absolute; + top: 101px; + right: 159px; + width: 35px; + height: 35px; + + @media (max-width: ${breakpoints.mobile}px) { + top: 20px; + right: 70px; + width: 30px; + height: 30px; + } +`; + +export default UsageExample; diff --git a/src/components/features/Landing/UserReviewSlider.tsx b/src/components/features/Landing/UserReviewSlider.tsx new file mode 100644 index 00000000..ecbbbfa1 --- /dev/null +++ b/src/components/features/Landing/UserReviewSlider.tsx @@ -0,0 +1,348 @@ +import CaretLeftIcon from '@/assets/Landing/caret_left.svg'; +import CaretRightIcon from '@/assets/Landing/caret_right.svg'; +import { Spacer } from '@/components/commons/Spacer'; +import { breakpoints } from '@/constants/breakpoints'; +import { useWindowWidth } from '@/hooks/useWindowWidth'; +import styled from '@emotion/styled'; +import { useEffect, useRef, useState } from 'react'; +import Slider from 'react-slick'; +import 'slick-carousel/slick/slick-theme.css'; +import 'slick-carousel/slick/slick.css'; + +const reviewList = [ + { + name: '김철수', + position: '코몬_4days', + content: '"처음엔 코드테스트가 낯설었지만, 팀과 함께 학습하며 자신감이 생겼어요. 못하던 부분도 차근차근 극복해나가는 과정이 가장 큰 수확이었습니다!"', + color: '#FF5780', + }, + { + name: '애순이', + position: '코몬_4days', + content: '"코몬 4days의 루틴이 제게는 약이 됐어요. \'오늘은 쉴까\' 했던 날도 팀원들의 인증 사진을 보면 결국 책상 앞에 앉게 되더라구요. 3개월간 주 4회+α로 풀며 카카오 코테 1차를 통과할 만큼 실력이 쑥쑥 자랐습니다!"', + color: '#6E74FA', + }, + { + name: '홍길동', + position: '코몬_4days', + content: '"원래 일주일에 한두 문제 풀던 제가, 코몬 4days 팀에 합류하니 일주일 4회는 기본이 되더라고요. 처음엔 벅찼지만, 팀원들과 서로 리마인드하고 피드백 주고받으니 2달 만에 목표했던 실버 등급 달성까지 성공했어요!"', + color: ' #FF5780', + }, + { + name: '족발', + position: '코몬_6days', + content: + '"혼자서는 매일 하기 힘들던 코테를, 팀원들과 약속으로 꾸준히 풀게 됐어요. 특히 스터디 전용 깃허브에 기록하니 빠진 날엔 바로 채워야 한다는 책임감이 생기더라구요. 덕분에 이전보다 문제 유형 분석 속도가 2배 빨라졌네요!"', + color: '#F15CA7', + }, + { + name: '동대구', + position: '싸피 a형 대비방', + content: '"매일 성실하게 문제를 풀다 보니, 어려웠던 유형도 자연스럽게 익혀졌어요. 함께 공부하는 동기부여가 되어 더 열심히 할 수 있었던 것 같아요!"', + color: '#6E74FA', + }, +]; + +const ReviewCard = ({ + name, + position, + content, + color, +}: { + name: string; + position: string; + content: string; + color: string; +}) => { + const isMobile = useWindowWidth() < breakpoints.mobile; + return ( + + {content} +
+ + {name} + {position} +
+
+ ); +}; + +export const UserReviewSlider = () => { + const sliderRef = useRef(null); + const [isMobile, setIsMobile] = useState( + typeof window !== 'undefined' && window.innerWidth < breakpoints.mobile, + ); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < breakpoints.mobile); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const settings = { + className: 'center', + centerMode: true, + infinite: true, + slidesToShow: isMobile ? 1 : 3, + slidesToScroll: 1, + speed: 500, + responsive: [ + { + breakpoint: breakpoints.mobile, + settings: { + slidesToShow: 1, + centerPadding: '145px', + centerMode: true, + slidesToScroll: 1, + }, + }, + { + breakpoint: 1024, + settings: { + slidesToShow: 3, + centerMode: true, + slidesToScroll: 1, + }, + }, + ], + }; + + return ( + <> + 함께한 사람들의 후기 + 코드몬스터와 함께 성장한 동료들의 생생한 후기✨ + + +
+ + {reviewList.map((review, index) => ( + + ))} + +
+ sliderRef.current?.slickPrev()} + > + + + sliderRef.current?.slickNext()} + > + + +
+ + + + ); +}; + +const Title = styled.div` + font-size: 36px; + font-weight: 700; + color: #111; + margin-bottom: 20px; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 24px; + margin-bottom: 10px; + } +`; +const SubTitle = styled.div` + font-size: 24px; + font-weight: 300; + color: #111; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 14px; + } +`; + +const StyledSlider = styled(Slider)` + .slick-list { + box-sizing: content-box; + margin: 0 100px; + @media (max-width: ${breakpoints.mobile}px) { + margin: 0 -120px; + } + } + .slick-slide { + display: flex; + justify-content: center; + align-items: center; + padding: 10px 0; + box-sizing: border-box; + } + + .slick-center .review-card { + z-index: 10; + } +`; + +const SliderWrapper = styled.div` + position: relative; + display: flex; + justify-content: center; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + padding: 20px 0; +`; + +const NavButton = styled.div` + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 56px; + height: 56px; + border-radius: 100px; + background-color: #00000029; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10; + + @media (max-width: ${breakpoints.mobile}px) { + width: 28px; + height: 28px; + } +`; + +const NavButtonPrev = styled(NavButton)` + left: 50%; + transform: translateY(-50%) translateX(-500px); + + @media (max-width: ${breakpoints.mobile}px) { + left: 15px; + transform: translateY(-50%) translateX(-50%); + } +`; + +const NavButtonNext = styled(NavButton)` + right: 50%; + transform: translateY(-50%) translateX(500px); + + @media (max-width: ${breakpoints.mobile}px) { + right: 0; + transform: translateY(-50%); + } +`; + + +const CaretIcon = styled.img` + width: 24px; + height: 24px; + @media (max-width: ${breakpoints.mobile}px) { + width: 12px; + height: 12px; + } +`; + +const CardContainer = styled.div` + position: relative; + padding: 30px 40px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 316px; + height: 180px; + border-radius: 20px; + background: #fff; + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + box-sizing: border-box; + transition: transform 0.3s ease-in-out, border 0.3s ease-in-out; + border: 1px solid transparent; + + .slick-center & { + transform: scale(1.05); + border: 1px solid #8488ec !important; + z-index: 10; + } + + @media (max-width: ${breakpoints.mobile}px) { + width: 316px; + height: 180px; + padding: 35px 26px; + } +`; + +const Circle = styled.div` + width: 20px; + height: 20px; + border-radius: 50%; + + @media (max-width: ${breakpoints.mobile}px) { + width: 18px; + height: 18px; + } +`; + +const Name = styled.div` + font-size: 16px; + font-weight: 600; + color: #333; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 14px; + } +`; + +const Position = styled.div` + font-size: 14px; + color: #111; + font-weight: 400; + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 12px; + } +`; + +const Content = styled.div` + font-size: 14px; + color: #767676; + line-height: 19px; + min-height: 100px; + font-weight: 400; + overflow: hidden; + + .slick-center & { + color: #111 !important; + font-weight: 400 !important; + } + + @media (max-width: ${breakpoints.mobile}px) { + font-size: 12px; + line-height: 18px; + min-height: 48px; + } +`; + +const Shadow = styled.div` + background: #D4D4D466; + filter: blur(4px); + width: 60%; + border-radius: 50%; + height: 19px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 100%; + height: 10px; + } +`; + +export default UserReviewSlider; \ No newline at end of file diff --git a/src/components/features/Post/PostEditor.tsx b/src/components/features/Post/PostEditor.tsx index 6004823f..92fa2c1b 100644 --- a/src/components/features/Post/PostEditor.tsx +++ b/src/components/features/Post/PostEditor.tsx @@ -1,5 +1,7 @@ import { viewStyle } from '@/utils/viewStyle'; +import { useImageCompressor } from '@/hooks/useImageCompressor.ts'; + import { ImageNode } from '@/components/features/Post/nodes/ImageNode'; import { ClipboardPlugin } from '@/components/features/Post/plugins/ClipboardPlugin.ts'; import { CodeActionPlugin } from '@/components/features/Post/plugins/CodeActionPlugin'; @@ -25,11 +27,12 @@ import { memo, useCallback, useEffect, + useRef, useState, } from 'react'; -import { requestPresignedUrl, toS3 } from '@/api/presignedurl.ts'; import { breakpoints } from '@/constants/breakpoints'; +import { postImagesAtom } from '@/store/posting'; import styled from '@emotion/styled'; import { CodeHighlightNode, CodeNode } from '@lexical/code'; import { AutoLinkNode, LinkNode } from '@lexical/link'; @@ -46,7 +49,11 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { addClassNamesToElement } from '@lexical/utils'; +import { useAtom } from 'jotai'; import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, $isLineBreakNode, DOMExportOutput, EditorThemeClasses, @@ -312,207 +319,205 @@ const EditorPlaceholder = styled.div` } `; -// TODO: 사용자 이벤트 추적하면서 이미지 순서 변경, 삭제, 추가를 트래킹하는 로직인데 나중에 필요할 수도 -// const blobUrlToFile = async (blobUrl: string, fileName: string) => { -// return await fetch(blobUrl, { -// mode: 'cors', -// // headers: { -// // 'Access-Control-Allow-Origin': 'https://test.codemonster.site/', -// // Origin: 'https://test.codemonster.site/', -// // }, -// }) -// .then((res) => res.blob()) -// .then((blob) => new File([blob], fileName, { type: blob.type })); -// }; -// -// const findImgElement = (element: HTMLElement): Promise => { -// return new Promise((resolve, reject) => { -// const maxAttempts = 10; // 10 * 100ms -// let attempts = 0; -// -// const intervalId = setInterval(() => { -// attempts++; -// const img = element.querySelector('img'); -// -// if (img) { -// clearInterval(intervalId); -// resolve(img); -// } else if (attempts >= maxAttempts) { -// clearInterval(intervalId); -// reject(new Error('Failed to find img element after max attempts')); -// } -// }, 100); -// }); -// }; - -// const useDetectImageMutation = () => { -// const [editor] = useLexicalComposerContext(); -// const [images, setImages] = useAtom(postImagesAtom); -// const { compressImage } = useImageCompressor({ quality: 1, maxSizeMb: 1 }); -// const firstNodeKey = useRef(''); -// -// useEffect(() => { -// Promise.resolve().then(() => { -// // if (firstNodeKey.current !== '') { -// editor.read(() => { -// const rootElement = editor.getRootElement(); -// if (!rootElement) { -// return; -// } -// const imgs = rootElement.querySelectorAll('.editor-image'); -// const imgNodes = Array.from(imgs) -// .map((img) => $getNearestNodeFromDOMNode(img)) -// .filter((imgNode) => imgNode !== null); -// -// if (imgNodes.length > 0) { -// firstNodeKey.current = imgNodes[0]?.getKey(); -// } -// }); -// // } -// }); -// }, [editor]); -// -// useEffect(() => { -// const unregisterMutationListener = editor.registerMutationListener( -// ImageNode, -// (mutations) => { -// mutations.forEach((mutation, nodeKey) => { -// if (mutation === 'created') { -// editor.update(() => { -// const element = editor.getElementByKey(nodeKey); -// if (!element) { -// return; -// } -// -// const node = $getNodeByKey(nodeKey); -// if (!node) { -// return; -// } -// -// console.log('nodeKey', firstNodeKey.current, images); -// // if (images.length === 0 && firstNodeKey.current === '') { -// if (firstNodeKey.current === '') { -// firstNodeKey.current = nodeKey; -// } -// // 이미지 최대 하나로 제한 -// else { -// if (nodeKey !== firstNodeKey.current) { -// node.remove(); -// alert('이미지는 최대 하나 까지만 넣을 수 있어요'); -// } -// return; -// } -// -// const parentNodeKey = node -// .getParentKeys() -// .filter((key) => key !== 'root')[0]; -// const parent = editor.getElementByKey(parentNodeKey); -// if (!parent) { -// return; -// } -// -// const imgs = parent.querySelectorAll('.editor-image'); -// const line = $getRoot() -// .getChildren() -// .findIndex((node) => node.getKey() === parentNodeKey); -// -// // 이거 아직 이미지가 하나 -// Promise.all( -// [...imgs].map((img) => -// findImgElement(img as HTMLElement).then((foundImg) => { -// let myNodeKey = ''; -// editor.read(() => { -// const node = $getNearestNodeFromDOMNode(img); -// if (node) { -// myNodeKey = node.getKey(); -// } -// }); -// return blobUrlToFile(foundImg.src, `img-${myNodeKey}`); -// }) -// ) -// ) -// .then(async (results) => { -// const compressedImg = []; -// -// for (const imgFile of results) { -// const requestId = `${imgFile.name}-${Date.now()}.jpg`; -// const res = await compressImage(requestId, imgFile); -// compressedImg.push(res); -// } -// -// const imgObjs = compressedImg.map((img, idx) => ({ -// key: img.name.split('-')[1].split('.')[0], -// img: img, -// line: line, -// idx: idx, -// })); -// -// setImages((prev) => { -// const filteredNewImages = imgObjs.filter( -// (newImg) => -// !prev.some( -// (img) => -// img.line === newImg.line && img.idx === newImg.idx -// ) -// ); -// if (filteredNewImages.length === 0) { -// return prev; -// } -// -// const targetNewLine = line; -// const rearrangedArr = prev.map((imgObj) => { -// if (imgObj.line > targetNewLine) { -// return { ...imgObj, line: imgObj.line + 1 }; -// } -// return imgObj; -// }); -// return [...rearrangedArr, ...filteredNewImages]; -// }); -// }) -// .catch((err) => { -// console.error('Promise.all error', err); -// editor.update(() => { -// const node = $getNodeByKey(nodeKey); -// if (node) { -// node.remove(); -// setTimeout(() => { -// alert('이미지 파일로 전환을 실패했습니다.'); -// }, 300); -// } -// }); -// }); -// }); -// return; -// } -// -// if (mutation === 'destroyed') { -// setImages((prev) => -// prev.filter((imgObj) => imgObj.key !== nodeKey) -// ); -// if (firstNodeKey.current === nodeKey) { -// firstNodeKey.current = ''; -// } -// } -// }); -// } -// ); -// -// return () => { -// // firstNodeKey.current = ''; -// unregisterMutationListener(); -// }; -// }, [editor, images]); -// }; +const blobUrlToFile = async (blobUrl: string, fileName: string) => { + return await fetch(blobUrl, { + mode: 'cors', + // headers: { + // 'Access-Control-Allow-Origin': 'https://test.codemonster.site/', + // Origin: 'https://test.codemonster.site/', + // }, + }) + .then((res) => res.blob()) + .then((blob) => new File([blob], fileName, { type: blob.type })); +}; + +const findImgElement = (element: HTMLElement): Promise => { + return new Promise((resolve, reject) => { + const maxAttempts = 10; // 10 * 100ms + let attempts = 0; + + const intervalId = setInterval(() => { + attempts++; + const img = element.querySelector('img'); + + if (img) { + clearInterval(intervalId); + resolve(img); + } else if (attempts >= maxAttempts) { + clearInterval(intervalId); + reject(new Error('Failed to find img element after max attempts')); + } + }, 100); + }); +}; + +const useDetectImageMutation = () => { + const [editor] = useLexicalComposerContext(); + const [images, setImages] = useAtom(postImagesAtom); + const { compressImage } = useImageCompressor({ quality: 1, maxSizeMb: 1 }); + const firstNodeKey = useRef(''); + + useEffect(() => { + Promise.resolve().then(() => { + // if (firstNodeKey.current !== '') { + editor.read(() => { + const rootElement = editor.getRootElement(); + if (!rootElement) { + return; + } + const imgs = rootElement.querySelectorAll('.editor-image'); + const imgNodes = Array.from(imgs) + .map((img) => $getNearestNodeFromDOMNode(img)) + .filter((imgNode) => imgNode !== null); + + if (imgNodes.length > 0) { + firstNodeKey.current = imgNodes[0]?.getKey(); + } + }); + // } + }); + }, [editor]); + + useEffect(() => { + const unregisterMutationListener = editor.registerMutationListener( + ImageNode, + (mutations) => { + mutations.forEach((mutation, nodeKey) => { + if (mutation === 'created') { + editor.update(() => { + const element = editor.getElementByKey(nodeKey); + if (!element) { + return; + } + + const node = $getNodeByKey(nodeKey); + if (!node) { + return; + } + + console.log('nodeKey', firstNodeKey.current, images); + // if (images.length === 0 && firstNodeKey.current === '') { + if (firstNodeKey.current === '') { + firstNodeKey.current = nodeKey; + } + // 이미지 최대 하나로 제한 + else { + if (nodeKey !== firstNodeKey.current) { + node.remove(); + alert('이미지는 최대 하나 까지만 넣을 수 있어요'); + } + return; + } + + const parentNodeKey = node + .getParentKeys() + .filter((key) => key !== 'root')[0]; + const parent = editor.getElementByKey(parentNodeKey); + if (!parent) { + return; + } + + const imgs = parent.querySelectorAll('.editor-image'); + const line = $getRoot() + .getChildren() + .findIndex((node) => node.getKey() === parentNodeKey); + + // 이거 아직 이미지가 하나 + Promise.all( + [...imgs].map((img) => + findImgElement(img as HTMLElement).then((foundImg) => { + let myNodeKey = ''; + editor.read(() => { + const node = $getNearestNodeFromDOMNode(img); + if (node) { + myNodeKey = node.getKey(); + } + }); + return blobUrlToFile(foundImg.src, `img-${myNodeKey}`); + }) + ) + ) + .then(async (results) => { + const compressedImg = []; + + for (const imgFile of results) { + const requestId = `${imgFile.name}-${Date.now()}.jpg`; + const res = await compressImage(requestId, imgFile); + compressedImg.push(res); + } + + const imgObjs = compressedImg.map((img, idx) => ({ + key: img.name.split('-')[1].split('.')[0], + img: img, + line: line, + idx: idx, + })); + + setImages((prev) => { + const filteredNewImages = imgObjs.filter( + (newImg) => + !prev.some( + (img) => + img.line === newImg.line && img.idx === newImg.idx + ) + ); + if (filteredNewImages.length === 0) { + return prev; + } + + const targetNewLine = line; + const rearrangedArr = prev.map((imgObj) => { + if (imgObj.line > targetNewLine) { + return { ...imgObj, line: imgObj.line + 1 }; + } + return imgObj; + }); + return [...rearrangedArr, ...filteredNewImages]; + }); + }) + .catch((err) => { + console.error('Promise.all error', err); + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (node) { + node.remove(); + setTimeout(() => { + alert('이미지 파일로 전환을 실패했습니다.'); + }, 300); + } + }); + }); + }); + return; + } + + if (mutation === 'destroyed') { + setImages((prev) => + prev.filter((imgObj) => imgObj.key !== nodeKey) + ); + if (firstNodeKey.current === nodeKey) { + firstNodeKey.current = ''; + } + } + }); + } + ); + + return () => { + // firstNodeKey.current = ''; + unregisterMutationListener(); + }; + }, [editor, images]); +}; const PostWriteSection = forwardRef< HTMLDivElement, { - imageCategory: string; children: React.ReactNode; } ->(({ imageCategory, children }, ref) => { +>(({ children }, ref) => { const [editor] = useLexicalComposerContext(); - // useDetectImageMutation(); + useDetectImageMutation(); const onPaste = useCallback( (e: ClipboardEvent) => { @@ -523,40 +528,13 @@ const PostWriteSection = forwardRef< if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile(); if (file) { - if (file.type.startsWith('image/')) { - const contentType = file.type; - const fileName = file.name; - const req = { - contentType: contentType, - fileName: fileName, - }; - - requestPresignedUrl({ - imageCategory: imageCategory, - requests: req, - file: file, - }) - .then(async (data) => { - const { contentType, presignedUrl } = data; - await toS3({ - url: presignedUrl, - contentType: contentType, - file: file, - }); - return presignedUrl; - }) - .then((url) => { - const imgPayload: InsertImagePayload = { - altText: '붙여넣은 이미지', - maxWidth: 600, - src: url.split('?')[0], - }; - editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload); - }) - .catch((err) => { - alert(err.response.message); - }); - } + const imageURL = URL.createObjectURL(file); + const imgPayload: InsertImagePayload = { + altText: '붙여넣은 이미지', + maxWidth: 600, + src: imageURL, + }; + editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload); } break; } @@ -616,9 +594,8 @@ const TitleInput = styled.input` const PostSectionWrap: React.FC<{ shouldHighlight?: boolean; - imageCategory: string; children: ReactNode; -}> = ({ shouldHighlight, imageCategory, children }) => { +}> = ({ shouldHighlight, children }) => { const [editor] = useLexicalComposerContext(); const onDrop = useCallback((event: React.DragEvent) => { @@ -626,51 +603,14 @@ const PostSectionWrap: React.FC<{ const files = event.dataTransfer.files; if (files.length > 0) { const file = files[0]; - console.log('??', file); if (file.type.startsWith('image/')) { - const contentType = file.type; - const fileName = file.name; - const req = { - contentType: contentType, - fileName: fileName, + const imageURL = URL.createObjectURL(file); + const imgPayload: InsertImagePayload = { + altText: '붙여넣은 이미지', + maxWidth: 600, + src: imageURL, }; - requestPresignedUrl({ - imageCategory: imageCategory, - requests: req, - file: file, - }) - .then(async (data) => { - const { contentType, presignedUrl } = data; - console.log('??', file); - await toS3({ - url: presignedUrl, - contentType: contentType, - file: file, // 사람이 붙여넣은 이미지 파일 - }); - return presignedUrl; - }) - .then((url) => { - const aa = url.split('?')[0]; - const imgPayload: InsertImagePayload = { - altText: '붙여넣은 이미지', - maxWidth: 600, - src: aa, - }; - editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload); - }) - .catch((err) => { - alert('이미지 업로드중 에러가 발생했습니다'); - console.error(err); - }); - - // console.log('contentType', req); - // const imageURL = URL.createObjectURL(file); - // const imgPayload: InsertImagePayload = { - // altText: '붙여넣은 이미지', - // maxWidth: 600, - // src: imageURL, - // }; - // editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload); } } }, []); @@ -691,22 +631,13 @@ const PostSectionWrap: React.FC<{ }; const PostEditor: React.FC<{ - imageCategory: string; forwardTitle?: (title: string) => void; forwardContent?: (content: string) => void; content?: string; title?: string; tag?: string; setTag?: (tag: string) => void; -}> = ({ - imageCategory, - forwardContent, - forwardTitle, - content, - setTag, - title, - tag, -}) => { +}> = ({ forwardContent, forwardTitle, content, setTag, title, tag }) => { const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); const [isLinkEditMode, setIsLinkEditMode] = useState(false); @@ -718,10 +649,7 @@ const PostEditor: React.FC<{ return ( - + - + } ErrorBoundary={LexicalErrorBoundary} diff --git a/src/components/features/Post/plugins/ClipboardPlugin.ts b/src/components/features/Post/plugins/ClipboardPlugin.ts index 985aa9e9..ff647417 100644 --- a/src/components/features/Post/plugins/ClipboardPlugin.ts +++ b/src/components/features/Post/plugins/ClipboardPlugin.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { $createCodeNode, DEFAULT_CODE_LANGUAGE } from '@lexical/code'; -import { $generateNodesFromDOM } from '@lexical/html'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $createRangeSelection, @@ -356,28 +355,7 @@ const registerCopyCommand = (editor: LexicalEditor) => { if ($isRangeSelection(selection) && selection.isCollapsed()) { shouldUseFallback = !copyCurrentLine(editor); } else if ($isRangeSelection(selection) && !selection.isCollapsed()) { - let selectedText = ''; - let nodesToCopy: Array = []; - - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return; - } - - selectedText = selection.getTextContent(); - - nodesToCopy = selection.getNodes(); - }); - - if (nodesToCopy.length > 0) { - copyNodesToClipboard(nodesToCopy, selectedText).then((success) => { - if (!success) { - navigator.clipboard.writeText(selectedText); - } - }); - shouldUseFallback = false; - } + shouldUseFallback = true; } else if (!$isRangeSelection(selection)) { shouldUseFallback = true; } @@ -426,40 +404,19 @@ const registerPasteCommand = (editor: LexicalEditor) => { try { const lexicalData = event.clipboardData.getData(LEXICAL_CLIPBOARD_TYPE); if (lexicalData) { - console.log('??', lexicalData); return false; } - const viewerData = event.clipboardData.getData('text/html-viewer'); - if (viewerData) { - const parser = new DOMParser(); - const doc = parser.parseFromString(viewerData, 'text/html'); - - editor.update(() => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) return; - - const nodes = $generateNodesFromDOM(editor, doc); - - if (nodes.length > 0) { - selection.insertNodes(nodes); - } - }); - return true; - } - const plainText = event.clipboardData.getData('text/plain'); if (plainText) { if (looksLikeCode(plainText)) { - // event.preventDefault(); + event.preventDefault(); const language = detectLanguageByPrism(plainText); editor.update(() => { const codeNode = $createCodeNode(); codeNode.setLanguage(language); codeNode.append($createTextNode(plainText)); - $insertNodes([codeNode]); }); diff --git a/src/components/features/Post/plugins/DraggablePlugin.tsx b/src/components/features/Post/plugins/DraggablePlugin.tsx index db2881d8..f3133401 100644 --- a/src/components/features/Post/plugins/DraggablePlugin.tsx +++ b/src/components/features/Post/plugins/DraggablePlugin.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -// import { postImagesAtom } from '@/store/posting.ts'; +import { postImagesAtom } from '@/store/posting.ts'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { eventFiles } from '@lexical/rich-text'; import { @@ -9,7 +9,7 @@ import { isHTMLElement, mergeRegister, } from '@lexical/utils'; -// import { useSetAtom } from 'jotai'; +import { useSetAtom } from 'jotai'; import { $getNearestNodeFromDOMNode, $getNodeByKey, @@ -482,7 +482,7 @@ const useDraggableBlockMenu = ( const isDraggingBlockRef = useRef(false); const [draggableBlockElem, setDraggableBlockElem] = useState(null); - // const setPostImages = useSetAtom(postImagesAtom); + const setPostImages = useSetAtom(postImagesAtom); useEffect(() => { const onMouseMove = (event: MouseEvent) => { @@ -606,57 +606,57 @@ const useDraggableBlockMenu = ( return; } - // const line = $getRoot() - // .getChildren() - // .findIndex((node) => node.getKey() === dragData); - // const imgNodeKeys = imgArray - // .map((img) => $getNearestNodeFromDOMNode(img)?.getKey()) - // .filter((key) => key !== undefined); + const line = $getRoot() + .getChildren() + .findIndex((node) => node.getKey() === dragData); + const imgNodeKeys = imgArray + .map((img) => $getNearestNodeFromDOMNode(img)?.getKey()) + .filter((key) => key !== undefined); // console.log('my img keys', imgArray, imgNodeKeys); - // 사용자 드래그 트래킹 - // setPostImages((prev) => { - // const targets = prev.filter((img) => imgNodeKeys.includes(img.key)); - // const targetOriginLine = targets[0].line; - // const targetNewLine = line; - // if (targetOriginLine === targetNewLine) { - // return prev; - // } - // - // const rangeStart = Math.min(targetOriginLine, targetNewLine); - // const rangeEnd = Math.max(targetOriginLine, targetNewLine); - // - // const rearrangedArr = prev.map((imgObj) => { - // if (imgNodeKeys.includes(imgObj.key)) { - // // console.log('이동됨', imgObj.key); - // return { ...imgObj, line: targetNewLine }; - // } - // - // if (imgObj.line >= rangeStart && imgObj.line <= rangeEnd) { - // if ( - // targetNewLine < targetOriginLine && - // imgObj.line >= targetNewLine - // // && imgObj.line < targetOriginLine - // ) { - // // console.log('아래로 밀러남', imgObj.key); - // return { ...imgObj, line: imgObj.line + 1 }; - // } - // - // if ( - // targetNewLine > targetOriginLine && - // imgObj.line > targetOriginLine - // // && imgObj.line <= targetNewLine - // ) { - // // console.log('위로 밀려남', imgObj.key); - // return { ...imgObj, line: imgObj.line - 1 }; - // } - // } - // // console.log('그대로', imgObj.key); - // return imgObj; - // }); - // console.error('rearrange fin', rearrangedArr); - // return rearrangedArr; - // }); + + setPostImages((prev) => { + const targets = prev.filter((img) => imgNodeKeys.includes(img.key)); + const targetOriginLine = targets[0].line; + const targetNewLine = line; + if (targetOriginLine === targetNewLine) { + return prev; + } + + const rangeStart = Math.min(targetOriginLine, targetNewLine); + const rangeEnd = Math.max(targetOriginLine, targetNewLine); + + const rearrangedArr = prev.map((imgObj) => { + if (imgNodeKeys.includes(imgObj.key)) { + // console.log('이동됨', imgObj.key); + return { ...imgObj, line: targetNewLine }; + } + + if (imgObj.line >= rangeStart && imgObj.line <= rangeEnd) { + if ( + targetNewLine < targetOriginLine && + imgObj.line >= targetNewLine + // && imgObj.line < targetOriginLine + ) { + // console.log('아래로 밀러남', imgObj.key); + return { ...imgObj, line: imgObj.line + 1 }; + } + + if ( + targetNewLine > targetOriginLine && + imgObj.line > targetOriginLine + // && imgObj.line <= targetNewLine + ) { + // console.log('위로 밀려남', imgObj.key); + return { ...imgObj, line: imgObj.line - 1 }; + } + } + // console.log('그대로', imgObj.key); + return imgObj; + }); + console.error('rearrange fin', rearrangedArr); + return rearrangedArr; + }); }); setDraggableBlockElem(null); diff --git a/src/components/features/Post/plugins/ToolbarPlugin.tsx b/src/components/features/Post/plugins/ToolbarPlugin.tsx index 8b5f499e..29511e33 100644 --- a/src/components/features/Post/plugins/ToolbarPlugin.tsx +++ b/src/components/features/Post/plugins/ToolbarPlugin.tsx @@ -71,11 +71,10 @@ const TAG_LIST: { ]; export const ToolbarPlugin: React.FC<{ - imageCategory: string; setIsLinkEditMode: Dispatch; setTag?: (tag: string) => void; articleCategory?: string; -}> = ({ imageCategory, setIsLinkEditMode, setTag, articleCategory }) => { +}> = ({ setIsLinkEditMode, setTag, articleCategory }) => { const [editor] = useLexicalComposerContext(); const [activeEditor, setActiveEditor] = useState(editor); @@ -223,18 +222,16 @@ export const ToolbarPlugin: React.FC<{ ) : ( -
- {!isRecruitPost && ( - 탬플릿 +
+ { !isRecruitPost && 탬플릿 } +
)} -
- )}
} @@ -344,7 +340,7 @@ export const ToolbarPlugin: React.FC<{ justifyContent: 'flex-end', }} > - {!setTag && !isRecruitPost && ( + { ( !setTag && !isRecruitPost) && ( 탬플릿 )}
} - imageCategory={imageCategory} />
diff --git a/src/components/features/Post/segments/ImageInputBox.tsx b/src/components/features/Post/segments/ImageInputBox.tsx index 45d780da..ced2a216 100644 --- a/src/components/features/Post/segments/ImageInputBox.tsx +++ b/src/components/features/Post/segments/ImageInputBox.tsx @@ -1,6 +1,5 @@ import { DragEventHandler, MutableRefObject, useState } from 'react'; -import { requestPresignedUrl, toS3 } from '@/api/presignedurl.ts'; // import { postImagesAtom } from '@/store/posting'; import styled from '@emotion/styled'; @@ -68,14 +67,14 @@ interface ImageInputBoxProps { imageInputRef: MutableRefObject; insertImage: (payload: { altText: string; src: string }) => void; closeImageInput: () => void; - imageCategory: string; } +const createImagePreviewUrl = (file: File) => URL.createObjectURL(file); + export const ImageInputBox: React.FC = ({ imageInputRef, insertImage, closeImageInput, - imageCategory, }) => { const [fileInput, setFileInput] = useState(null); const [urlInput, setUrlInput] = useState(''); @@ -102,47 +101,8 @@ export const ImageInputBox: React.FC = ({ }); return; } - - if (fileInput) { - const uploadToS3 = async (file: File) => { - const contentType = file.type; - const fileName = file.name; - const req = { - contentType: contentType, - fileName: fileName, - }; - - const data = await requestPresignedUrl({ - imageCategory: imageCategory, - requests: req, - file: file, - }); - - const { contentType: contentTypeRes, presignedUrl } = data; - await toS3({ - url: presignedUrl, - contentType: contentTypeRes, - file: file, - }); - return presignedUrl; - }; - - uploadToS3(fileInput) - .then((url) => { - insertImage({ - src: url.split('?')[0], - altText: altText || '이미지', - }); - closeImageInput(); - }) - .catch((err) => { - alert(err.response.message); - }); - return; - } - insertImage({ - src: urlInput, + src: fileInput ? createImagePreviewUrl(fileInput) : urlInput, altText: altText || '이미지', }); // setPostImages((prev) => [...prev, fileInput]); diff --git a/src/components/features/Post/segments/InsertImageButton.tsx b/src/components/features/Post/segments/InsertImageButton.tsx index 97faa46b..e9a63149 100644 --- a/src/components/features/Post/segments/InsertImageButton.tsx +++ b/src/components/features/Post/segments/InsertImageButton.tsx @@ -7,8 +7,7 @@ import { createPortal } from 'react-dom'; export const InsertImageButton: React.FC<{ insertImage: (payload: InsertImagePayload) => void; buttonLabel: ReactNode; - imageCategory: string; -}> = ({ insertImage, buttonLabel, imageCategory }) => { +}> = ({ insertImage, buttonLabel }) => { const [showImageInsertBox, setShowImageInsertBox] = useState(false); const buttonRef = useRef(null); const imageInputRef = useRef(null); @@ -56,7 +55,6 @@ export const InsertImageButton: React.FC<{ imageInputRef={imageInputRef} insertImage={insertImage} closeImageInput={closeImageInputBox} - imageCategory={imageCategory} />, document.body )} diff --git a/src/components/features/TeamDashboard/ArticleDetail.tsx b/src/components/features/TeamDashboard/ArticleDetail.tsx index 679a4c49..40b34c7b 100644 --- a/src/components/features/TeamDashboard/ArticleDetail.tsx +++ b/src/components/features/TeamDashboard/ArticleDetail.tsx @@ -66,7 +66,6 @@ export const ArticleDetail: React.FC = ({ setConfirm({ message: '게시글을 삭제하시겠습니까?', description: '삭제된 게시글은 복구되지 않아요', - confirmText: '삭제', isVisible: true, onConfirm: deleteArticle, onCancel: () => {}, diff --git a/src/components/features/TeamDashboard/SidebarAndAnnouncement.tsx b/src/components/features/TeamDashboard/SidebarAndAnnouncement.tsx index 82bd661f..8980d018 100644 --- a/src/components/features/TeamDashboard/SidebarAndAnnouncement.tsx +++ b/src/components/features/TeamDashboard/SidebarAndAnnouncement.tsx @@ -1,5 +1,3 @@ -import { isDevMode, isLoggedIn } from '@/utils/cookie.ts'; - import { useWindowWidth } from '@/hooks/useWindowWidth'; import { Box } from '@/components/commons/Box'; @@ -16,8 +14,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'; import { ITeamInfo } from '@/api/team'; import AnnouncementIcon from '@/assets/TeamDashboard/announcement_purple.png'; import TriangleIcon from '@/assets/TeamDashboard/invert_triangle.png'; -import LockIcon from '@/assets/TeamDashboard/lock.svg'; -import MessageIcon from '@/assets/TeamDashboard/message_circle.svg'; +import LockIcon from '@/assets/TeamDashboard/lock.png'; +import MessageIcon from '@/assets/TeamDashboard/message_circle.png'; import PencilIcon from '@/assets/TeamDashboard/pencil.png'; import SettingsGreenIcon from '@/assets/TeamDashboard/settings_green.png'; import SettingsPurpleIcon from '@/assets/TeamDashboard/settings_purple.png'; @@ -25,10 +23,11 @@ import SettingsRedIcon from '@/assets/TeamDashboard/settings_red.png'; import { breakpoints } from '@/constants/breakpoints'; import { colors } from '@/constants/colors'; import { PATH } from '@/routes/path'; +import { isLoggedInAtom } from '@/store/auth'; import { selectedPostIdAtom } from '@/store/dashboard'; import { confirmAtom } from '@/store/modal'; import styled from '@emotion/styled'; -import { useSetAtom } from 'jotai'; +import { useAtom, useSetAtom } from 'jotai'; interface ISidebarAndAnnouncementProps { teamInfo: ITeamInfo; @@ -53,6 +52,7 @@ export const SidebarAndAnnouncement: React.FC = ({ const toggleExpand = () => setIsExpanded((prev) => !prev); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const setConfirm = useSetAtom(confirmAtom); + const [isLoggedIn] = useAtom(isLoggedInAtom); const onClick = () => { if (isMyTeam) { @@ -69,7 +69,7 @@ export const SidebarAndAnnouncement: React.FC = ({ }; const joinTeam = () => { - if (!isLoggedIn() && !isDevMode()) { + if (!isLoggedIn) { sessionStorage.setItem('redirect', location.pathname); navigate(PATH.LOGIN, { state: { @@ -82,21 +82,12 @@ export const SidebarAndAnnouncement: React.FC = ({ }; const goRecruitPage = () => { - if (isLoggedIn() || isDevMode()) { - if (teamInfo.teamRecruitId) { - navigate(`${PATH.TEAM_RECRUIT}/detail/${teamInfo.teamRecruitId}`); - } else { - toast.error('참가할 수 없는 상태입니다.'); - } + if (teamInfo.teamRecruitId) { + navigate(`${PATH.TEAM_RECRUIT}/detail/${teamInfo.teamRecruitId}`); } else { - sessionStorage.setItem('redirect', location.pathname); - navigate(PATH.LOGIN, { - state: { - redirect: location.pathname, - }, - }); - }; - } + toast.error('참가할 수 없는 상태입니다.'); + } + }; const handleClick = () => { if (teamInfo.teamRecruitId) { @@ -106,7 +97,7 @@ export const SidebarAndAnnouncement: React.FC = ({ message: '현재 모집글이 없습니다.', description: '새로 작성하시겠어요?', isVisible: true, - cancelText: '취소', + cancleText: '취소', confirmText: '작성하기', onConfirm: () => { navigate(`${PATH.TEAM_RECRUIT}/posting`, { @@ -451,12 +442,6 @@ const MoreIcon = styled.img` height: 10px; margin-left: 8px; padding-top: 2px; - - @media (max-width: ${breakpoints.mobile}px) { - width: 8px; - height: 5px; - margin-left: 4px; - } `; const NewPostButton = styled.button` @@ -496,14 +481,12 @@ const DropdownWrapper = styled.div` padding: 18px 52px; box-sizing: border-box; box-shadow: 2px 2px 20px 0px #5e609933; - border: 1px solid var(--light-selection, #E5E5E5); @media (max-width: ${breakpoints.mobile}px) { - width: 200px; - bottom: -96px; + width: 100px; + bottom: -50px; right: 10px; - padding: 11px 11px; - gap: 10px; + padding: 8px 4px; } `; @@ -513,22 +496,20 @@ const DropdownList = styled.div` align-items: center; width: 100%; cursor: pointer; - box-sizing: border-box; @media (max-width: ${breakpoints.mobile}px) { - gap: 20px; - padding: 8px 22px; - border-radius: 5px; - - &:hover { - background: #F8F8FF; - } + gap: 8px; } `; const DropdownListIcon = styled.img` width: 18px; height: 18px; + + @media (max-width: ${breakpoints.mobile}px) { + width: 8px; + height: 8px; + } `; const DropdownListText = styled.div` @@ -537,9 +518,6 @@ const DropdownListText = styled.div` font-weight: 600; @media (max-width: ${breakpoints.mobile}px) { - width: 90px; - text-align: center; - font-size: 14px; - font-weight: 500; + font-size: 6px; } `; diff --git a/src/components/features/TeamDashboard/TopicDetail.tsx b/src/components/features/TeamDashboard/TopicDetail.tsx index 904c5cd6..946144bc 100644 --- a/src/components/features/TeamDashboard/TopicDetail.tsx +++ b/src/components/features/TeamDashboard/TopicDetail.tsx @@ -9,7 +9,7 @@ import { LazyImage } from '@/components/commons/LazyImage'; import { SText } from '@/components/commons/SText'; import { Spacer } from '@/components/commons/Spacer'; -import { Suspense, useRef } from 'react'; +import { Suspense } from 'react'; import { Link } from 'react-router-dom'; import { getTeamTopic } from '@/api/dashboard'; @@ -57,32 +57,6 @@ export const TopicDetail: React.FC = ({ const { result: selectedTopicBody } = useRegroupImageAndArticle(data); - const textRef = useRef(null); - - const handleCopy = (event: React.ClipboardEvent) => { - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) return; - - const range = selection.getRangeAt(0); - - if ( - !textRef.current || - !textRef.current.contains(range.commonAncestorContainer) - ) - return; - - const fragment = range.cloneContents(); - const div = document.createElement('div'); - div.appendChild(fragment); - const html = div.innerHTML; - const text = selection.toString(); - - event.preventDefault(); - event.clipboardData?.setData('text/html-viewer', html); - event.clipboardData?.setData('text/plain', text); - }; - return data ? ( @@ -109,6 +83,7 @@ export const TopicDetail: React.FC = ({ articleId: data?.articleId, articleTitle: data?.articleTitle, articleCategory: data?.articleCategory, + articleImageUrl: data?.imageUrl, }} > = ({ {data ? ( ) : null} diff --git a/src/components/features/TeamRecruit/TeamRecruitPosting.tsx b/src/components/features/TeamRecruit/TeamRecruitPosting.tsx index 6d510bdd..a7383ec8 100644 --- a/src/components/features/TeamRecruit/TeamRecruitPosting.tsx +++ b/src/components/features/TeamRecruit/TeamRecruitPosting.tsx @@ -4,8 +4,7 @@ import { Flex } from '@/components/commons/Flex'; import { SText } from '@/components/commons/SText'; import { Spacer } from '@/components/commons/Spacer'; import PostEditor from '@/components/features/Post/PostEditor'; -import { TeamRecruitSubject } from '@/components/features/TeamRecruit/RecruitExampleData'; -import { getRecruitDefaultData } from '@/components/features/TeamRecruit/RecruitExampleData'; +import { getRecruitDefaultData, TeamRecruitSubject } from '@/components/features/TeamRecruit/RecruitExampleData'; import TeamRecruitInput from '@/components/features/TeamRecruit/TeamRecruitInput'; import { useRef, useState } from 'react'; @@ -21,8 +20,9 @@ import { colors } from '@/constants/colors'; import { PostSubjectViewer } from '@/pages/Posting/PostSubjectViewer'; import { PATH } from '@/routes/path'; import { alertAtom } from '@/store/modal'; +import { postImagesAtom } from '@/store/posting'; import styled from '@emotion/styled'; -import { useSetAtom } from 'jotai'; +import { useAtom, useSetAtom } from 'jotai'; export const TeamRecruitPosting = () => { const isMobile = window.innerWidth < breakpoints.mobile; @@ -41,6 +41,7 @@ export const TeamRecruitPosting = () => { ); const [title, setTitle] = useState(teamRecruitTitle ?? ''); const [url, setUrl] = useState(chatUrl ?? ''); + const [postImages, setPostImages] = useAtom(postImagesAtom); const chatUrlRef = useRef(null); const setAlert = useSetAtom(alertAtom); const [disablePrompt, setDisablePrompt] = useState(false); @@ -55,17 +56,34 @@ export const TeamRecruitPosting = () => { const onClick = (event: React.MouseEvent) => { event.preventDefault(); - const teamRecruitBody = content.trim(); + const teamRecruitBodyTrim = content.trim(); + + const teamRecruitBody = + postImages.length > 0 + ? teamRecruitBodyTrim.replace(/(]*src=")[^"]*(")/g, '$1?$2') + : teamRecruitBodyTrim; if (recruitId) { console.log('recruitId', recruitId); modifyRecruitPost({ teamRecruitTitle: title, teamRecruitBody: teamRecruitBody, + image: + postImages.length > 0 + ? postImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img) + : null, chatUrl: url, recruitmentId: recruitId, }) .then(() => { + setPostImages([]); setDisablePrompt(true); setAlert({ message: '모집글을 수정했어요', @@ -88,9 +106,21 @@ export const TeamRecruitPosting = () => { teamId: teamId, teamRecruitTitle: title, teamRecruitBody: teamRecruitBody, + image: + postImages.length > 0 + ? postImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img) + : null, chatUrl: url, }) .then((res) => { + setPostImages([]); setDisablePrompt(true); setAlert({ message: '모집글을 생성했어요', @@ -123,51 +153,6 @@ export const TeamRecruitPosting = () => { forwardTitle={setTitle} content={content} title={title} - imageCategory={'TEAM_RECRUIT'} - /> - - - - - - - 연락 방법 - - - - (필수) 방장은 팀 관리와 운영을 위해 연락 방법을 반드시 기재해야 해요 - - - - - - - - - - {recruitId ? '수정 완료' : '작성 완료'} - - - - diff --git a/src/components/layout/CommonLayout.tsx b/src/components/layout/CommonLayout.tsx index f3014ba5..e72320d2 100644 --- a/src/components/layout/CommonLayout.tsx +++ b/src/components/layout/CommonLayout.tsx @@ -27,7 +27,6 @@ export const CommonLayout: React.FC<{ }> = ({ children }) => { const location = useLocation(); const prevPathRef = useRef(null); - const isHomePage = location.pathname === '/'; useLayoutEffect(() => { const currPath = location.pathname.split('/')[1]; @@ -57,15 +56,14 @@ export const CommonLayout: React.FC<{
{children} - {!isHomePage && } + ); diff --git a/src/hooks/useRegroupImageAndArticle.ts b/src/hooks/useRegroupImageAndArticle.ts index 7d4cf4f9..21f53cec 100644 --- a/src/hooks/useRegroupImageAndArticle.ts +++ b/src/hooks/useRegroupImageAndArticle.ts @@ -33,11 +33,11 @@ const regroupArticle = (data: IArticle | ITopicResponse | MyArticle) => { } // if (!data.imageUrls || data.imageUrls.length === 0) { - // if (!data.imageUrl) { - return data.articleBody; - // } else { - // return data.articleBody?.replace(/src="\?"/, `src="${data.imageUrl}"`); - // } + if (!data.imageUrl) { + return data.articleBody; + } else { + return data.articleBody?.replace(/src="\?"/, `src="${data.imageUrl}"`); + } // TODO: 이미지 하나 // let imgIndex = 0; // diff --git a/src/main.tsx b/src/main.tsx index 66cbd8f3..b8310409 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,11 +2,10 @@ import { isDevMode } from '@/utils/cookie.ts'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; -import { router } from '@/routes/router'; import '@/styles/reset.css'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { App } from './App'; import './main.css'; @@ -35,7 +34,7 @@ registerFileCacheWorker(); createRoot(document.getElementById('root')!).render( - + ); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 7de397ba..ce1680b4 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -3,46 +3,56 @@ import { Flex } from '@/components/commons/Flex'; import { SText } from '@/components/commons/SText'; import { Spacer } from '@/components/commons/Spacer'; import { QnAList } from '@/components/features/Home/QnAList'; -import { ReviewSlider } from '@/components/features/Home/ReviewSlider'; import { CommonLayout } from '@/components/layout/CommonLayout'; +import GroupIcon from '@/assets/Landing/group_icon.svg'; +import ManageIcon from '@/assets/Landing/manage_icon.svg'; +import RecordIcon from '@/assets/Landing/record_icon.svg'; + import { Fragment } from 'react'; -import { useNavigate } from 'react-router-dom'; import logo from '@/assets/Header/logo.svg'; import bgCurve from '@/assets/Home/bg-curve.svg'; import bgRing from '@/assets/Home/bg-ring.svg'; import bgZigzag from '@/assets/Home/bg-zigzag.svg'; import faq from '@/assets/Home/faq.svg'; -import instagram from '@/assets/Home/instagram.svg'; import logoLight from '@/assets/Home/logo-light.svg'; -import name from '@/assets/Home/name-last.svg'; -import notion from '@/assets/Home/notion.svg'; -import { PATH } from '@/routes/path'; +import { AnimatedLanding } from '@/components/features/Landing/AnimatedLanding'; +import { Banner } from '@/components/features/Landing/Banner'; +import ServiceStrength from '@/components/features/Landing/ServiceStrength'; +import UsageExample from '@/components/features/Landing/UsageExample'; +import UserReviewSlider from '@/components/features/Landing/UserReviewSlider'; +import { breakpoints } from '@/constants/breakpoints'; +import { useWindowWidth } from '@/hooks/useWindowWidth'; import styled from '@emotion/styled'; export const Home = () => { - const navigate = useNavigate(); - const onClickStart = () => navigate(`${PATH.TEAM_RECRUIT}/list`); + // const navigate = useNavigate(); + // const onClickStart = () => navigate(`${PATH.TEAM_RECRUIT}/list`); + const isMobile = useWindowWidth() < breakpoints.mobile; return ( {/* 민경 */}
- - - + +
+
+ + + - + 혼자서는 지치기 쉬운 코딩테스트,
@@ -53,9 +63,9 @@ export const Home = () => { color="#767676" textAlign="center" fontFamily="Pretendard" - fontSize="24px" + fontSize={isMobile ? '14px' : '20px'} fontWeight={300} - lineHeight="34px" + lineHeight={isMobile ? '20px' : '34px'} > 코드몬스터는 코딩테스트 준비를 위한 스터디 플랫폼입니다.
@@ -64,73 +74,78 @@ export const Home = () => { 풀이를 공유하며 지속적인 성장을 이끌어내는 커뮤니티입니다.
+
+ + + +
{/* TODO: 카드 3개 영역 */}
-
+
+
+
- {/* 지수 */} -
+
+ - - - 함께한 사람들의 후기 - - - - 코드몬스터와 함께 성장한 동료들의 생생한 후기✨ - - - - - + +
-
+ + + {/* 지수 */} + +
FAQ - + + 궁금한 점이 있으신가요? 자주 묻는 질문을 모아봤어요! - - FAQ + + FAQ
-
+ {/*
@@ -156,7 +171,7 @@ export const Home = () => { notion -
+
*/} ); @@ -169,6 +184,16 @@ const Section = styled.section<{ backgroundColor?: string }>` display: flex; flex-direction: column; align-items: center; + box-sizing: border-box; + width: 100vw; + left: 50%; + margin-left: -50vw; + right: 50%; + margin-right: -50vw; + + @media (max-width: ${breakpoints.mobile}px) { + padding: 40px 0; + } `; const SubHeader = ({ @@ -178,14 +203,15 @@ const SubHeader = ({ text: string; theme: 'dark' | 'light'; }) => { + const isMobile = useWindowWidth() < breakpoints.mobile; return ( - + @@ -197,17 +223,21 @@ const SubHeader = ({ const Logo = styled.img` height: 16px; -`; -const Ellipse = styled.div` - width: 900px; - height: 19px; - border-radius: 900px; - background: rgba(212, 212, 212, 0.4); - filter: blur(8px); - border-radius: 50%; + @media (max-width: ${breakpoints.mobile}px) { + height: 8px; + } `; +// const Ellipse = styled.div` +// width: 900px; +// height: 19px; +// border-radius: 900px; +// background: rgba(212, 212, 212, 0.4); +// filter: blur(8px); +// border-radius: 50%; +// `; + const Decoration = styled.img<{ top: string; left?: string; @@ -221,18 +251,19 @@ const Decoration = styled.img<{ width: ${({ width }) => width || '120px'}; opacity: 0.8; z-index: 1; -`; -const StartButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - height: 74px; - padding: 24px 40px 24px 52px; - gap: 4px; - border-radius: 40px; - background: #333; - color: #fff; - font-size: 36px; - font-weight: 400; `; + +// const StartButton = styled.button` +// display: flex; +// align-items: center; +// justify-content: center; +// height: 74px; +// padding: 24px 40px 24px 52px; +// gap: 4px; +// border-radius: 40px; +// background: #333; +// color: #fff; +// font-size: 36px; +// font-weight: 400; +// `; diff --git a/src/pages/Posting/PostSubjectViewer.tsx b/src/pages/Posting/PostSubjectViewer.tsx index a47ee1eb..03698414 100644 --- a/src/pages/Posting/PostSubjectViewer.tsx +++ b/src/pages/Posting/PostSubjectViewer.tsx @@ -103,30 +103,6 @@ export const PostSubjectViewer: React.FC<{ const { result } = useRegroupImageAndArticle(data); - const handleCopy = (event: React.ClipboardEvent) => { - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) return; - - const range = selection.getRangeAt(0); - - if ( - !contentRef.current || - !contentRef.current.contains(range.commonAncestorContainer) - ) - return; - - const fragment = range.cloneContents(); - const div = document.createElement('div'); - div.appendChild(fragment); - const html = div.innerHTML; - const text = selection.toString(); - - event.preventDefault(); - event.clipboardData?.setData('text/html-viewer', html); - event.clipboardData?.setData('text/plain', text); - }; - return ( { const location = useLocation(); @@ -47,6 +48,7 @@ const Posting = () => { const [postTitle, setPostTitle] = useState(() => articleTitle ?? ''); const [isPending, setIsPending] = useState(false); const [disablePrompt, setDisablePrompt] = useState(false); + const [postImages, setPostImages] = useAtom(postImagesAtom); const setSelectedPostId = useSetAtom(selectedPostIdAtom); const setDashboardView = useSetAtom(currentViewAtom); const setAlert = useSetAtom(alertAtom); @@ -84,13 +86,29 @@ const Posting = () => { } setIsPending(true); - const articleBody = content.trim(); + const articleBodyTrim = content.trim(); + + const articleBody = + postImages.length > 0 + ? articleBodyTrim.replace(/(]*src=")[^"]*(")/g, '$1?$2') + : articleBodyTrim; if (article && articleId && articleTitle) { mutatePost({ teamId: parseInt(id), + images: + postImages.length > 0 + ? postImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img) + : null, articleId: parseInt(articleId), - articleBody: articleBody, + articleBody: postImages ? articleBody : content, articleTitle: postTitle, }) .then(() => { @@ -101,6 +119,7 @@ const Posting = () => { .then(() => { setDashboardView('article'); setSelectedPostId(articleId); + setPostImages([]); setDisablePrompt(true); setAlert({ message: '게시글을 수정했어요', @@ -146,6 +165,17 @@ const Posting = () => { createPost({ teamId: parseInt(id), + images: + postImages.length > 0 + ? postImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img) + : null, articleBody: articleBody, articleTitle: postTitle, }) @@ -158,7 +188,9 @@ const Posting = () => { .then(() => { setDashboardView('article'); setSelectedPostId(articleId); + setPostImages([]); setDisablePrompt(true); + // navigate(`/team-dashboard/${id}`); setAlert({ message: '글쓰기를 완료했어요', isVisible: true, @@ -209,7 +241,6 @@ const Posting = () => { forwardTitle={setPostTitle} content={article} title={articleTitle} - imageCategory={'ARTICLE'} /> { const location = useLocation(); - const { articleId, articleCategory, articleBody, articleTitle } = - location?.state ?? { - articleId: null, - articleCategory: null, - articleBody: null, - articleTitle: null, - }; + const { + articleId, + articleCategory, + articleBody, + articleTitle, + // TODO: 이미지 하나 허용으로 롤백 + // articleImageUrls, + articleImageUrl, + } = location?.state ?? { + articleId: null, + articleCategory: null, + articleBody: null, + articleTitle: null, + // articleImageUrls: null, + articleImageUrl: null, + }; const locationData = { articleBody: articleBody, articleId: articleId, articleTitle: articleTitle, articleCategory: articleCategory, + // TODO: 이미지 하나 허용으로 롤백 + // imageUrls: articleImageUrls, + imageUrl: articleImageUrl, } as ITopicResponse; const { result: regroupedArticleContent } = @@ -56,6 +69,7 @@ const TeamDailySubject = () => { const [tag, setTag] = useState(() => articleCategory ?? ''); const [isPending, setIsPending] = useState(false); const [disablePrompt, setDisablePrompt] = useState(false); + const [subjectImages, setSubjectImages] = useAtom(postImagesAtom); const setAlert = useSetAtom(alertAtom); const setSelectedPostId = useSetAtom(selectedPostIdAtom); const setDashboardView = useSetAtom(currentViewAtom); @@ -87,7 +101,9 @@ const TeamDailySubject = () => { if (isPending) { return; } - const replacedArticleBody = content.trim(); + const replacedArticleBody = subjectImages + ? content.trim().replace(/(]*src=")[^"]*(")/g, '$1?$2') + : content.trim(); if (articleId && tag && articleBody && subjectTitle) { setIsPending(true); @@ -96,6 +112,17 @@ const TeamDailySubject = () => { articleId: parseInt(articleId), articleTitle: subjectTitle, articleBody: replacedArticleBody, + images: + subjectImages.length > 0 + ? subjectImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img) + : null, articleCategory: tag, }) .then(() => { @@ -141,6 +168,14 @@ const TeamDailySubject = () => { articleTitle: subjectTitle, selectedDate: selectedDate, articleBody: replacedArticleBody, + images: subjectImages + .sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.idx - b.idx; + }) + .map((imgObj) => imgObj.img), articleCategory: tag, }) .then((data) => { @@ -157,6 +192,7 @@ const TeamDailySubject = () => { alert('최신 팀 상태 조회에 실패했습니다.'); }) .finally(() => { + setSubjectImages([]); setDashboardView('topic'); setSelectedPostId(parseInt(articleId)); setDisablePrompt(true); @@ -192,7 +228,6 @@ const TeamDailySubject = () => { content={regroupedArticleContent} title={articleTitle} tag={articleCategory} - imageCategory={'ARTICLE'} /> { }); // 가장 비용이 적은 캐싱 if (isPaginationReady && articlesData) { - totalPageCache = articlesData.page.totalPages; + totalPageCache = articlesData.totalPages; } const handleShowTopicDetail = () => { @@ -528,7 +528,7 @@ const TeamAdmin = () => { onShowArticleDetail={handleShowArticleDetail} /> { }); // 가장 비용이 적은 캐싱 if (isPaginationReady && articlesData) { - totalPageCache = articlesData.page.totalPages; + totalPageCache = articlesData.totalPages; } const onClickCalendarDate = (newDate: string) => { @@ -138,7 +138,7 @@ const TeamDashboardPage = () => { onShowArticleDetail={handleShowArticleDetail} /> { select: (data) => ({ myTeams: data.myTeams, otherTeams: data.allTeams.content, - totalPages: data.allTeams.page.totalPages, + totalPages: data.allTeams.totalPages, }), retry: (failureCount, error: AxiosError>) => { if ( diff --git a/src/pages/TeamRecruit/TeamRecruitDetail.tsx b/src/pages/TeamRecruit/TeamRecruitDetail.tsx index 1eab6727..13ff1a58 100644 --- a/src/pages/TeamRecruit/TeamRecruitDetail.tsx +++ b/src/pages/TeamRecruit/TeamRecruitDetail.tsx @@ -550,14 +550,8 @@ export const TeamRecruitDetail = () => { color="#333" onClick={() => { if (window.confirm('정말로 삭제하시겠습니까?')) { - deleteRecruit(recruitId, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['teamRecruits'], - }); - navigate(`${PATH.TEAM_RECRUIT}/list`); - }, - }); + deleteRecruit(recruitId); + navigate(`${PATH.TEAM_RECRUIT}/list`); } }} > @@ -808,10 +802,6 @@ const TeamRecruitBody = styled.div` line-height: 1.5; ${viewStyle} - - @media (max-width: ${breakpoints.mobile}px) { - line-height: 1.75; - } `; const ChatLink = styled.a` diff --git a/src/pages/TeamRecruit/TeamRecruitList.tsx b/src/pages/TeamRecruit/TeamRecruitList.tsx index db085c7c..e5870307 100644 --- a/src/pages/TeamRecruit/TeamRecruitList.tsx +++ b/src/pages/TeamRecruit/TeamRecruitList.tsx @@ -368,7 +368,7 @@ const CardBody = styled.div` font-size: 16px; font-weight: 500; font-family: 'Pretendard'; - line-height: 1.5 !important; + line-height: normal !important; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -378,10 +378,10 @@ const CardBody = styled.div` * { // all: unset; color: #000 !important; - font-size: inherit !important; + font-size: 16px !important; font-weight: 500 !important; font-family: 'Pretendard' !important; - line-height: inherit !important; + line-height: normal !important; } img { diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 4d9738d8..ea5155e2 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,5 +1,3 @@ -import { checkRemainingCookies, isDevMode } from '@/utils/cookie'; - import TeamRecruitPosting from '@/components/features/TeamRecruit/TeamRecruitPosting'; import { MultiSectionLayout } from '@/components/layout/MultiSectionHeader'; import { SSLWithPathAtom } from '@/components/layout/SSLWithPathAtom'; @@ -19,6 +17,7 @@ import { TeamRecruitDetail } from '@/pages/TeamRecruit/TeamRecruitDetail'; import { TeamRecruitListPage } from '@/pages/TeamRecruit/TeamRecruitList'; import { LazyEnrollTemplate, LazySkeleton } from '@/routes/Lazies'; import { PATH } from '@/routes/path'; +import { isLoggedInAtom } from '@/store/auth'; import { LoginTemplate } from '@/templates/Login/LoginTemplate'; import MyDashboard from '@/templates/MyDashboard/MyDashboard.tsx'; import { MyTeams } from '@/templates/MyDashboard/MyTeams'; @@ -27,15 +26,18 @@ import MemberModification from '@/templates/Team/MemberModification'; import { TeamInformation } from '@/templates/Team/TeamInformation'; import TeamRegistrationTemplate from '@/templates/Team/TeamRegistrationTemplate.tsx'; import TeamSetting from '@/templates/Team/TeamSetting'; +import { useAtom } from 'jotai'; const useAuth = () => { - if (isDevMode()) { - return { - isAuthenticated: true, - }; - } + const [isLoggedIn] = useAtom(isLoggedInAtom); + // if (isDevMode()) { + // return { + // isAuthenticated: true, + // }; + // } + console.log('isLoggedIn', isLoggedIn); return { - isAuthenticated: checkRemainingCookies(), + isAuthenticated: isLoggedIn, }; }; diff --git a/src/store/auth.ts b/src/store/auth.ts new file mode 100644 index 00000000..c3d79422 --- /dev/null +++ b/src/store/auth.ts @@ -0,0 +1,31 @@ +import api from '@/api/apiInstance'; +import type { ProfileQueryResp } from '@/api/user'; +import { getMyProfile } from '@/api/user'; +import { atom } from 'jotai'; + +export const authStatusAtom = atom<'authenticated' | 'guest'>('guest'); +export const profileAtom = atom(null); +export const authInitDoneAtom = atom(false); +export const isLoggedInAtom = atom((get) => get(authStatusAtom) === 'authenticated'); + +export const refreshAuthAtom = atom(null, async (_get, set) => { + try { + const data = await getMyProfile(); + set(profileAtom, data); + set(authStatusAtom, 'authenticated'); + } catch { + set(profileAtom, null); + set(authStatusAtom, 'guest'); + } finally { + set(authInitDoneAtom, true); + } +}); + +export const logoutAtom = atom(null, async (_get, set) => { + try { + await api.post('/v1/logout'); + } finally { + set(profileAtom, null); + set(authStatusAtom, 'guest'); + } +}); \ No newline at end of file diff --git a/src/store/form.ts b/src/store/form.ts index 87cc4e36..93b7ded3 100644 --- a/src/store/form.ts +++ b/src/store/form.ts @@ -63,8 +63,8 @@ export const formTextareaAtom = atom( } ); -const userPictureStorageAtom = atom(''); -const teamPictureStorageAtom = atom(''); +const userPictureStorageAtom = atom(null); +const teamPictureStorageAtom = atom(null); export const MAX_IMAGE_SIZE = 10; export const isImageFitAtom = atom(null); @@ -73,12 +73,23 @@ export const imageAtom = atom( get(isOnUserFormAtom) ? get(userPictureStorageAtom) : get(teamPictureStorageAtom), - (get, set, imgSrc: string) => { - set(isImageFitAtom, true); + (get, set, file: File) => { + const allowedTypes = ['image/png', 'image/jpeg']; + if (!allowedTypes.includes(file.type)) { + return; + } + + const fileSizeInMB = file.size / 1024 / 1024; + if (fileSizeInMB > MAX_IMAGE_SIZE) { + set(isImageFitAtom, false); + return; + } else { + set(isImageFitAtom, true); + } set( get(isOnUserFormAtom) ? userPictureStorageAtom : teamPictureStorageAtom, - imgSrc + file ); } ); diff --git a/src/store/modal.ts b/src/store/modal.ts index b1801ef8..87c40625 100644 --- a/src/store/modal.ts +++ b/src/store/modal.ts @@ -1,29 +1,17 @@ import { atom } from 'jotai'; -export const alertAtom = atom<{ - message: string; - isVisible: boolean; - onConfirm: () => void; -}>({ +export const alertAtom = atom<{ message: string; isVisible: boolean; onConfirm: () => void }>({ message: '', isVisible: false, onConfirm: () => {}, }); -export const confirmAtom = atom<{ - message: string; - description: string; - isVisible: boolean; - cancelText?: string; - confirmText?: string; - onConfirm: () => void; - onCancel: () => void; -}>({ +export const confirmAtom = atom<{ message: string; description: string; isVisible: boolean; cancleText?: string; confirmText?: string; onConfirm: () => void; onCancel: () => void }>({ message: '', description: '', isVisible: false, - cancelText: '취소', + cancleText: '취소', confirmText: '나가기', onConfirm: () => {}, onCancel: () => {}, -}); +}); \ No newline at end of file diff --git a/src/templates/Login/LoginTemplate.tsx b/src/templates/Login/LoginTemplate.tsx index 69d9ad78..5da9c66f 100644 --- a/src/templates/Login/LoginTemplate.tsx +++ b/src/templates/Login/LoginTemplate.tsx @@ -1,4 +1,4 @@ -import { checkIfLoggedIn, handleCookieOnRedirect } from '@/utils/cookie'; +import { checkIfLoggedIn } from '@/utils/cookie'; import { GradientGlassPanel } from '@/components/commons/GradientGlassPanel'; import { Spacer } from '@/components/commons/Spacer'; @@ -14,7 +14,7 @@ export const LoginTemplate = () => { const location = useLocation(); useEffect(() => { - handleCookieOnRedirect(); + // handleCookieOnRedirect(); if (checkIfLoggedIn()) { const sessionRedirect = sessionStorage.getItem('redirect'); if (sessionRedirect) { diff --git a/src/templates/MyDashboard/Profile.tsx b/src/templates/MyDashboard/Profile.tsx index 9eadfc00..9308af46 100644 --- a/src/templates/MyDashboard/Profile.tsx +++ b/src/templates/MyDashboard/Profile.tsx @@ -7,7 +7,6 @@ import { SText } from '@/components/commons/SText'; import { Fragment, Suspense, useState } from 'react'; -import { s3 } from '@/api/presignedurl.ts'; import { ProfileQueryResp, changeProfile, @@ -260,32 +259,27 @@ export const Profile = () => { }); const image = (formValues['image'] ?? null) as File; - const mutateProfile = (url: string) => - changeProfile({ - memberName: formValues['memberName'] as string, - memberExplain: formValues['memberExplain'] as string, - imageUrl: url, + + changeProfile({ + memberName: formValues['memberName'] as string, + memberExplain: formValues['memberExplain'] as string, + image: image.size !== 0 ? image : null, + }) + .then(() => { + queryClient + .refetchQueries({ + queryKey: ['my-profile-query'], + }) + .then(() => { + setMode('query'); + if (image) { + setImage(image); + } + alert('프로필 변환에 성공했습니다'); + }) + .catch(() => alert('변환된 프로필 조회를 실패했습니다')); }) - .then(() => { - queryClient - .refetchQueries({ - queryKey: ['my-profile-query'], - }) - .then(() => { - setMode('query'); - if (url) { - setImage(url); - } - alert('프로필 변환에 성공했습니다'); - }) - .catch(() => alert('변환된 프로필 조회를 실패했습니다')); - }) - .catch(() => alert('프로필 변환에 실패했습니다')); - if (image.size > 0) { - s3('PROFILE', image, mutateProfile); - } else { - mutateProfile(''); - } + .catch(() => alert('프로필 변환에 실패했습니다')); }; return ( @@ -374,12 +368,7 @@ const ProfileModifier = () => { return ( 이미지 - + 이름 ( + <> + 팀 아이콘 + + + 팀 이름 + + + 팀 설명 + + + ); + return ( {isMobile ? ( - <> - 팀 아이콘 - - - 팀 이름 - - - 팀 설명 - - + ) : ( <> 팀 이름 @@ -69,7 +73,7 @@ export const TeamForm: React.FC< /> 팀 아이콘 - + )} diff --git a/src/templates/Team/TeamInformation.tsx b/src/templates/Team/TeamInformation.tsx index 0fd32609..994bc2a9 100644 --- a/src/templates/Team/TeamInformation.tsx +++ b/src/templates/Team/TeamInformation.tsx @@ -10,7 +10,6 @@ import { getTeamInfoAdmin, modifyTeam, } from '@/api/team'; -import InfoIcon from '@/assets/TeamAdmin/info_square.png'; import noteIcon from '@/assets/TeamDashboard/note.png'; import { breakpoints } from '@/constants/breakpoints'; import { PATH } from '@/routes/path'; @@ -25,6 +24,7 @@ import { import styled from '@emotion/styled'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAtom } from 'jotai'; +import InfoIcon from '@/assets/TeamAdmin/info_square.png'; import TeamModification from './TeamModification'; @@ -180,7 +180,7 @@ export const TeamInformation = () => { const pas = password ?? currentTeam.password; const curr = `${currentTeam.teamName}-${currentTeam.teamExplain}-null-${currentTeam.topic}-${currentTeam.memberLimit}-${currentTeam.password}`; - const changed = `${name}-${exp}-${image}-${top}-${mem}-${pas}`; + const changed = `${name}-${exp}-${image?.lastModified ?? null}-${top}-${mem}-${pas}`; setIsDirty(curr !== changed); }, [teamName, teamExplain, topic, memberLimit, image, password, currentTeam]); @@ -334,9 +334,9 @@ const ContentWrapper = styled.div` padding: 30px 28px; box-sizing: border-box; min-width: 100%; - border: 1px solid #f0f1ff; + border: 1px solid #F0F1FF; backdrop-filter: blur(40px); - box-shadow: 5px 7px 11.6px 0px #3f3f4d12; + box-shadow: 5px 7px 11.6px 0px #3F3F4D12; height: 450px; } `; diff --git a/src/templates/Team/TeamModification.tsx b/src/templates/Team/TeamModification.tsx index ef0e2960..bfa5c501 100644 --- a/src/templates/Team/TeamModification.tsx +++ b/src/templates/Team/TeamModification.tsx @@ -7,12 +7,12 @@ import { Fragment } from 'react'; import { TeamAdminResponse } from '@/api/team'; import { breakpoints } from '@/constants/breakpoints'; -import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { TeamMaxPeopleInput } from './segments/TeamMaxPeopleInput'; import { TeamPasswordInput } from './segments/TeamPasswordInput'; import { TeamSubjectRadio } from './segments/TeamSubjectRadio'; +import { css } from '@emotion/react'; interface ModificationProps { currentTeam: TeamAdminResponse; @@ -46,9 +46,8 @@ const SuspenseTeamForm: React.FC = ({ currentTeam }) => { 팀 아이콘 @@ -57,17 +56,11 @@ const SuspenseTeamForm: React.FC = ({ currentTeam }) => { 인원 제한 - + 입장 비밀번호 - + ); @@ -87,6 +80,7 @@ const TeamModification: React.FC = ({ currentTeam }) => { }; const MobileInput = css` + @media (max-width: ${breakpoints.mobile}px) { border: none; padding: 0; @@ -103,6 +97,7 @@ const MobileInput = css` `; const MobileRadio = css` + @media (max-width: ${breakpoints.mobile}px) { display: flex; gap: 6px; @@ -149,4 +144,6 @@ const FormFieldLabel = styled.label` } `; + + export default TeamModification; diff --git a/src/templates/Team/TeamModificationButton.tsx b/src/templates/Team/TeamModificationButton.tsx index 0b9654a0..fdd4e6c0 100644 --- a/src/templates/Team/TeamModificationButton.tsx +++ b/src/templates/Team/TeamModificationButton.tsx @@ -44,7 +44,7 @@ export const TeamModificationButton = () => { const pas = password ?? currPassword; const curr = `${currTeamName}-${currTeamExplain}-null-${currTopic}-${currMemberLimit}-${currPassword}`; - const changed = `${name}-${exp}-${image}-${top}-${mem}-${pas}`; + const changed = `${name}-${exp}-${image?.lastModified ?? null}-${top}-${mem}-${pas}`; setIsDirty(curr !== changed); }, [teamName, teamExplain, topic, memberLimit, image, password]); diff --git a/src/templates/User/EnrollForm.tsx b/src/templates/User/EnrollForm.tsx index 69795073..d4e1323f 100644 --- a/src/templates/User/EnrollForm.tsx +++ b/src/templates/User/EnrollForm.tsx @@ -35,7 +35,7 @@ const EnrollFormContainer = styled.div` const MobileComponent = () => ( <> 프로필 이미지 - + 닉네임 @@ -57,7 +57,7 @@ export const EnrollForm: React.FC = ({ h }) => { 프로필 이미지 - + ) : ( diff --git a/src/templates/User/EnrollSubmitButton.tsx b/src/templates/User/EnrollSubmitButton.tsx index 18d0d2f5..b071ede6 100644 --- a/src/templates/User/EnrollSubmitButton.tsx +++ b/src/templates/User/EnrollSubmitButton.tsx @@ -28,7 +28,7 @@ export const EnrollSubmitButton = () => { createProfile({ memberName: memberName, memberExplain: memberExplain, - imageUrl: image, + image: image, }) .then(() => { // const previousPath = location.state?.redirect ?? PATH.TEAMS; diff --git a/src/templates/User/ModificationForm.tsx b/src/templates/User/ModificationForm.tsx index da3e912e..3e95680a 100644 --- a/src/templates/User/ModificationForm.tsx +++ b/src/templates/User/ModificationForm.tsx @@ -65,7 +65,6 @@ export const ModificationForm: React.FC = ({ h }) => { key={`${data?.imageUrl}`} imageUrl={data?.imageUrl} isDisabled={isFetching} - imageCategory={'PROFILE'} /> 프로필 설명 diff --git a/src/templates/User/ModificationSubmitButton.tsx b/src/templates/User/ModificationSubmitButton.tsx index 5fb3cf2f..f01ae503 100644 --- a/src/templates/User/ModificationSubmitButton.tsx +++ b/src/templates/User/ModificationSubmitButton.tsx @@ -6,12 +6,21 @@ import { changeProfile } from '@/api/user'; import { formTextInputAtom, formTextareaAtom, imageAtom } from '@/store/form'; import { useAtomValue } from 'jotai'; +const serializeImage = (file: File | null): string => { + if (!file) return 'NULL'; + + return JSON.stringify({ + name: file.name, + type: file.type, + }); +}; + const serializeProfile = ( - image: string | null, + image: File | null, memberName: string, memberExplain: string ) => { - return '' + image + memberName + memberExplain; + return serializeImage(image) + memberName + memberExplain; }; export const ModificationSubmitButton = () => { @@ -25,7 +34,7 @@ export const ModificationSubmitButton = () => { ); const onClick = () => { - changeProfile({ memberName, memberExplain, imageUrl: image }) + changeProfile({ memberName, memberExplain, image }) .then(() => alert('프로필 변환에 성공했습니다')) .catch(() => alert('프로필 변환에 실패했습니다')); }; diff --git a/src/templates/User/utils.ts b/src/templates/User/utils.ts index 827a55de..137cd699 100644 --- a/src/templates/User/utils.ts +++ b/src/templates/User/utils.ts @@ -1,12 +1,20 @@ export const serializeForm = ( name: string, explain: string, - image: string | null, + image: File | null, topic?: string, memberLimit?: string | number ): string => { if (topic || memberLimit) { - return name + explain + image + topic + memberLimit; + return ( + name + + explain + + image?.name + + image?.size + + image?.type + + topic + + memberLimit + ); } - return name + explain + image; + return name + explain + image?.name + image?.size + image?.type; }; diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts index cafd89b1..f1a1c0ee 100644 --- a/src/utils/cookie.ts +++ b/src/utils/cookie.ts @@ -1,3 +1,4 @@ + export const getCookieAsJson = (): Record => { return document.cookie .split(';') @@ -37,6 +38,7 @@ export const checkRemainingCookies = () => { return sessionStorage.getItem('Authorization') !== null; }; + export const isLoggedIn = () => sessionStorage.getItem('Authorization') !== null; diff --git a/src/utils/viewStyle.ts b/src/utils/viewStyle.ts index 1501fc60..32d30bc2 100644 --- a/src/utils/viewStyle.ts +++ b/src/utils/viewStyle.ts @@ -117,10 +117,6 @@ export const viewStyle = `& { .editor-listitem { margin: 8px 32px 8px 32px; - - @media (max-width: ${breakpoints.mobile}px) { - margin: 0; - } } .nested {