From 5c72bc8dee2eda0ac292870e126bad33afa32ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EA=B8=B8/KIM=20YOUNG=20GIL?= <80146176+Gilpop8663@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:38:18 +0900 Subject: [PATCH] =?UTF-8?q?Release=201.1.1=20=EC=B6=9C=EC=8B=9C=20(#615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: (#598) 로그인 시 쿠키를 주고 받을 수 있도록 fetch 설정 변경 (#600) * cookie의 path를 /auth에서 /로 변경 및 RefreshToken TTL설정 (#610) * refactor: (#608) cookie의 path를 루트 경로로 지정 * refactor: (#608) refreshtoken을 redis에 저장할 때 ttl설정 * 구글 애널리틱스 작동을 위한 모듈 설치, 게시글 본문의 링크 클릭 가능하도록 구현 (#613) * fix: (#612) react-gtm-module을 react-ga4 로 대체, RouteChangeTracker 컴포넌트 추가 * feat: (#606) 게시글 작성 시 링크 넣기 기능 추가 * feat: (#606) 게시글 본문에 링크 있으면 a 태그로 인식되도록 구현 * chore: (#606) 공지사항 설명 수정 * chore: (#606) 불필요한 주석 삭제 * 로그인, 투표 통계, 회원정보 페이지 lazy import 적용 (#571) * feat: (#556) lazy import 설정 및 트리쉐이킹 설정 * feat: (#556) 로그인, 투표 통계, 회원정보 입력 페이지 lazy import 적용 * feat: (#556) 번들 이름 매번 바뀌도록 변경 및 수정되었던 코드 복구 * feat: (#556) Suspense 코드 복구 --------- Co-authored-by: jero_kang <81199414+inyeong-kang@users.noreply.github.com> * 게시글 작성 시 이미지 파일을 보낼 때 webp 로 압축하여 성능 개선 (#614) * feat: (#555) browser-image-compression 설치 및 본문 이미지 훅에 적용 * feat: (#555) 선택지 옵션 사진을 webp로 변환하도록 구현 --------- Co-authored-by: 최우창 Co-authored-by: Jun-Hyeok Sin Co-authored-by: jero_kang <81199414+inyeong-kang@users.noreply.github.com> Co-authored-by: jeomxon Co-authored-by: aiaiaiai1 Co-authored-by: chsua <113416448+chsua@users.noreply.github.com> Co-authored-by: lookh <103165859+aiaiaiai1@users.noreply.github.com> Co-authored-by: 김영길/KIM YOUNG GIL Co-authored-by: jero_kang Co-authored-by: chsua --- .../auth/controller/AuthController.java | 4 +- .../domain/auth/service/AuthService.java | 3 +- frontend/env.d.ts | 2 +- frontend/package-lock.json | 18 ++++---- frontend/package.json | 3 +- frontend/public/seo/sitemap.xml | 6 +-- frontend/src/App.tsx | 30 ++++++------- .../src/components/GoogleTagManager/index.tsx | 10 ----- frontend/src/components/PostForm/index.tsx | 15 ++++++- frontend/src/components/PostForm/style.ts | 27 ++++++++++++ .../components/RouteChangeTracker/index.tsx | 25 +++++++++++ frontend/src/components/common/Post/index.tsx | 10 ++--- frontend/src/components/common/Post/style.ts | 4 +- frontend/src/hooks/useContentImage.ts | 18 +++++--- frontend/src/hooks/useText.ts | 6 ++- frontend/src/hooks/useWritingOption.tsx | 22 +++++++--- frontend/src/index.tsx | 5 +++ frontend/src/pages/Announcement/index.tsx | 4 +- frontend/src/routes/router.tsx | 40 ++++++++++++++--- frontend/src/utils/post/formatContentLink.tsx | 43 +++++++++++++++++++ frontend/src/utils/resizeImage.ts | 17 ++++++++ frontend/webpack.common.js | 2 +- 22 files changed, 240 insertions(+), 74 deletions(-) delete mode 100644 frontend/src/components/GoogleTagManager/index.tsx create mode 100644 frontend/src/components/RouteChangeTracker/index.tsx create mode 100644 frontend/src/utils/post/formatContentLink.tsx create mode 100644 frontend/src/utils/resizeImage.ts diff --git a/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java b/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java index e9fcacb86..781a0a9f1 100644 --- a/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java @@ -75,7 +75,7 @@ private void addRefreshTokenToCookie(final HttpServletResponse httpServletRespon final ResponseCookie responseCookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(true) - .path("/auth") + .path("/") .maxAge(1209600) .sameSite(SameSite.NONE.attributeValue()) .build(); @@ -94,7 +94,7 @@ private void expireCookie(final HttpServletResponse httpServletResponse, final S final ResponseCookie responseCookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(true) - .path("/auth") + .path("/") .maxAge(0) .sameSite(SameSite.NONE.attributeValue()) .build(); diff --git a/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java b/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java index 58cc022e9..3cc786337 100644 --- a/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java @@ -14,6 +14,7 @@ import com.votogether.global.jwt.TokenPayload; import com.votogether.global.jwt.TokenProcessor; import com.votogether.global.jwt.exception.JsonException; +import java.time.Duration; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; @@ -58,7 +59,7 @@ public ReissuedTokenDto reissueAuthToken( final String newAccessToken = tokenProcessor.generateAccessToken(accessTokenPayload.memberId()); final String newRefreshToken = tokenProcessor.generateRefreshToken(accessTokenPayload.memberId()); - redisTemplate.opsForValue().set(newRefreshToken, accessTokenPayload.memberId()); + redisTemplate.opsForValue().set(newRefreshToken, accessTokenPayload.memberId(), Duration.ofDays(14L)); return new ReissuedTokenDto(newAccessToken, newRefreshToken); } diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 92d39203d..9a14c4258 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -5,6 +5,6 @@ declare module NodeJS { VOTOGETHER_REST_API_KEY: string; VOTOGETHER_SERVER_REDIRECT_URL: string; VOTOGETHER_CHANNEL_TALK_KEY: string; - VOTOGETHER_GOOGLE_TAG_ID: string; + VOTOGETHER_GOOGLE_ANALYTICS_ID: string; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5c5cffdb..fd8b4dfad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "msw": "^1.2.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-gtm-module": "^2.0.11", + "react-ga4": "^2.1.0", "react-router-dom": "^6.14.1" }, "devDependencies": { @@ -20216,10 +20216,10 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, - "node_modules/react-gtm-module": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", - "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + "node_modules/react-ga4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", + "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" }, "node_modules/react-inspector": { "version": "6.0.2", @@ -38066,10 +38066,10 @@ } } }, - "react-gtm-module": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", - "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + "react-ga4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", + "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" }, "react-inspector": { "version": "6.0.2", diff --git a/frontend/package.json b/frontend/package.json index e41a01775..edfcc6404 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,11 +20,12 @@ }, "dependencies": { "@tanstack/react-query": "^4.29.19", + "browser-image-compression": "^2.0.2", "dotenv": "^16.3.1", "msw": "^1.2.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-gtm-module": "^2.0.11", + "react-ga4": "^2.1.0", "react-router-dom": "^6.14.1" }, "eslintConfig": { diff --git a/frontend/public/seo/sitemap.xml b/frontend/public/seo/sitemap.xml index 33657e5c2..7f4f6d631 100644 --- a/frontend/public/seo/sitemap.xml +++ b/frontend/public/seo/sitemap.xml @@ -6,16 +6,16 @@ > https://votogether.com/ - 2023-09-13T04:22:25.347Z + 2023-09-14T06:06:57.224Z https://votogether.com/login - 2023-09-13T04:22:25.347Z + 2023-09-14T06:06:57.224Z https://votogether.com/ranking - 2023-09-13T04:22:25.347Z + 2023-09-14T06:06:57.224Z diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fc051421..0d7708033 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -11,7 +12,7 @@ import router from '@routes/router'; import ErrorBoundaryForTopClass from '@pages/ErrorBoundaryForTopClass'; import ChannelTalk from '@components/ChannelTalk'; -import GoogleTagManager from '@components/GoogleTagManager'; +import Skeleton from '@components/common/Skeleton'; import { GlobalStyle } from '@styles/globalStyle'; import { theme } from '@styles/theme'; @@ -24,21 +25,20 @@ ChannelTalk.boot({ }); const App = () => ( - <> - - - - - - + + + + + + + }> - - - - - - - + + + + + + ); export default App; diff --git a/frontend/src/components/GoogleTagManager/index.tsx b/frontend/src/components/GoogleTagManager/index.tsx deleted file mode 100644 index e301f817a..000000000 --- a/frontend/src/components/GoogleTagManager/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useEffect } from 'react'; -import TagManager from 'react-gtm-module'; - -export default function GoogleTagManager({ gtmId }: { gtmId: string }) { - useEffect(() => { - TagManager.initialize({ gtmId }); - }); - - return <>; -} diff --git a/frontend/src/components/PostForm/index.tsx b/frontend/src/components/PostForm/index.tsx index 390aad3b4..69ee2dad3 100644 --- a/frontend/src/components/PostForm/index.tsx +++ b/frontend/src/components/PostForm/index.tsx @@ -98,7 +98,11 @@ export default function PostForm({ data, mutate }: PostFormProps) { }; const { text: writingTitle, handleTextChange: handleTitleChange } = useText(title ?? ''); - const { text: writingContent, handleTextChange: handleContentChange } = useText(content ?? ''); + const { + text: writingContent, + handleTextChange: handleContentChange, + addText: addContent, + } = useText(content ?? ''); const multiSelectHook = useMultiSelect(categoryIds ?? [], CATEGORY_COUNT_LIMIT); const handleDeadlineButtonClick = (option: DeadlineOption) => { @@ -121,6 +125,10 @@ export default function PostForm({ data, mutate }: PostFormProps) { } }; + const handleInsertContentLink = () => { + addContent('[[이 괄호 안에 링크를 작성해주세요]] '); + }; + const handlePostFormSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(); @@ -227,6 +235,11 @@ export default function PostForm({ data, mutate }: PostFormProps) { minLength={POST_CONTENT.MIN_LENGTH} required /> + + + 본문에 링크 넣기 + + diff --git a/frontend/src/components/PostForm/style.ts b/frontend/src/components/PostForm/style.ts index 95b3540b9..5c46bca19 100644 --- a/frontend/src/components/PostForm/style.ts +++ b/frontend/src/components/PostForm/style.ts @@ -117,6 +117,33 @@ export const Content = styled.textarea` } `; +export const ContentLinkButtonWrapper = styled.div` + width: 100%; + height: 36px; + margin-bottom: 5px; + + @media (max-width: ${theme.breakpoint.sm}) { + } +`; + +export const Button = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: 2px solid var(--primary-color); + border-radius: 5px; + padding: 5px 0; + + color: var(--primary-color); + background-color: white; + + font-size: 16px; + + cursor: pointer; +`; + export const ContentImagePartWrapper = styled.div<{ $hasImage: boolean }>` justify-self: ${props => props.$hasImage && 'center'}; height: 100%; diff --git a/frontend/src/components/RouteChangeTracker/index.tsx b/frontend/src/components/RouteChangeTracker/index.tsx new file mode 100644 index 000000000..dbdf4bf42 --- /dev/null +++ b/frontend/src/components/RouteChangeTracker/index.tsx @@ -0,0 +1,25 @@ +/* src/RouteChangeTracker.js */ +import { useEffect, useState } from 'react'; +import ReactGA from 'react-ga4'; +import { useLocation } from 'react-router-dom'; + +export default function RouteChangeTracker() { + const location = useLocation(); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (process.env.VOTOGETHER_GOOGLE_ANALYTICS_ID) { + ReactGA.initialize(process.env.VOTOGETHER_GOOGLE_ANALYTICS_ID); + setInitialized(true); + } + }, []); + + useEffect(() => { + if (initialized) { + ReactGA.set({ page: location.pathname }); + ReactGA.send('pageview'); + } + }, [initialized, location]); + + return <>; +} diff --git a/frontend/src/components/common/Post/index.tsx b/frontend/src/components/common/Post/index.tsx index 4d78c9509..fb4f2de06 100644 --- a/frontend/src/components/common/Post/index.tsx +++ b/frontend/src/components/common/Post/index.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, useContext, useEffect } from 'react'; +import { useContext, useEffect } from 'react'; import { PostInfo } from '@type/post'; @@ -13,6 +13,7 @@ import { PATH } from '@constants/path'; import { POST } from '@constants/vote'; import { convertImageUrlToServerUrl } from '@utils/post/convertImageUrlToServerUrl'; +import { linkifyText } from '@utils/post/formatContentLink'; import { checkClosedPost, convertTimeToWord } from '@utils/time'; import photoIcon from '@assets/photo_white.svg'; @@ -77,10 +78,6 @@ export default function Post({ postInfo, isPreview }: PostProps) { }); }; - const handleLinkClick = (e: MouseEvent) => { - if (!isPreview) e.preventDefault(); - }; - useEffect(() => { if (isCreateError && createError instanceof Error) { openToast(createError.message); @@ -107,7 +104,6 @@ export default function Post({ postInfo, isPreview }: PostProps) { as={isPreview ? '' : 'main'} to={isPreview ? `${PATH.POST}/${postId}` : '#'} $isPreview={isPreview} - onClick={handleLinkClick} aria-describedby={ isPreview ? '해당 게시물의 상세페이지로 이동하기' @@ -162,7 +158,7 @@ export default function Post({ postInfo, isPreview }: PostProps) { aria-label={`내용: ${content}`} $isPreview={isPreview} > - {content} + {linkifyText(content)} {!isPreview && imageUrl && ( diff --git a/frontend/src/components/common/Post/style.ts b/frontend/src/components/common/Post/style.ts index df7a5c798..46a99b7d9 100644 --- a/frontend/src/components/common/Post/style.ts +++ b/frontend/src/components/common/Post/style.ts @@ -92,7 +92,7 @@ export const Wrapper = styled.div` } `; -export const Content = styled.p<{ $isPreview: boolean }>` +export const Content = styled.div<{ $isPreview: boolean }>` display: -webkit-box; margin: 10px 0; @@ -116,8 +116,6 @@ export const DetailLink = styled(Link)<{ $isPreview: boolean }>` display: flex; flex-direction: column; gap: 10px; - - pointer-events: ${({ $isPreview }) => !$isPreview && 'none'}; `; export const Image = styled.img` diff --git a/frontend/src/hooks/useContentImage.ts b/frontend/src/hooks/useContentImage.ts index 85dfd196e..8a859eed2 100644 --- a/frontend/src/hooks/useContentImage.ts +++ b/frontend/src/hooks/useContentImage.ts @@ -2,6 +2,8 @@ import { ChangeEvent, useRef, useState } from 'react'; import { MAX_FILE_SIZE } from '@components/PostForm/constants'; +import { convertImageToWebP } from '@utils/resizeImage'; + export const useContentImage = (imageUrl: string = '') => { const [contentImage, setContentImage] = useState(imageUrl); const contentInputRef = useRef(null); @@ -11,13 +13,23 @@ export const useContentImage = (imageUrl: string = '') => { if (contentInputRef.current) contentInputRef.current.value = ''; }; - const handleUploadImage = (event: ChangeEvent) => { + const handleUploadImage = async (event: ChangeEvent) => { const { files } = event.target; if (!files) return; const file = files[0]; + const webpFileList = await convertImageToWebP(file); + + event.target.files = webpFileList; + + const reader = new FileReader(); + + const webpFile = webpFileList[0]; + + reader.readAsDataURL(webpFile); + event.target.setCustomValidity(''); if (file.size > MAX_FILE_SIZE) { @@ -27,10 +39,6 @@ export const useContentImage = (imageUrl: string = '') => { return; } - const reader = new FileReader(); - - reader.readAsDataURL(file); - reader.onloadend = () => { setContentImage(reader.result?.toString() ?? ''); }; diff --git a/frontend/src/hooks/useText.ts b/frontend/src/hooks/useText.ts index 6ff6ce6c6..c2fe7cd0c 100644 --- a/frontend/src/hooks/useText.ts +++ b/frontend/src/hooks/useText.ts @@ -26,5 +26,9 @@ export const useText = (originalText: string) => { setText(''); }; - return { text, setText, handleTextChange, resetText }; + const addText = (newTextToAdd: string) => { + setText(text + newTextToAdd); + }; + + return { text, setText, handleTextChange, resetText, addText }; }; diff --git a/frontend/src/hooks/useWritingOption.tsx b/frontend/src/hooks/useWritingOption.tsx index 7211b24ce..4b465b0c6 100644 --- a/frontend/src/hooks/useWritingOption.tsx +++ b/frontend/src/hooks/useWritingOption.tsx @@ -2,6 +2,8 @@ import React, { ChangeEvent, useState } from 'react'; import { MAX_FILE_SIZE } from '@components/PostForm/constants'; +import { convertImageToWebP } from '@utils/resizeImage'; + const MAX_WRITING_LENGTH = 50; export interface WritingVoteOptionType { @@ -78,13 +80,26 @@ export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = IN setOptionList(updatedOptionList); }; - const handleUploadImage = (event: React.ChangeEvent, optionId: number) => { + const handleUploadImage = async ( + event: React.ChangeEvent, + optionId: number + ) => { const { files } = event.target; if (!files) return; const file = files[0]; + const webpFileList = await convertImageToWebP(file); + + event.target.files = webpFileList; + + const reader = new FileReader(); + + const webpFile = webpFileList[0]; + + reader.readAsDataURL(webpFile); + event.target.setCustomValidity(''); if (file.size > MAX_FILE_SIZE) { @@ -94,11 +109,6 @@ export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = IN return; } - const reader = new FileReader(); - - // readAsDataURL 메서드를 통해 파일을 모두 읽고 나면 reader의 loadend 이벤트에서 이미지 미리보기 결과를 확인할 수 있습니다. - reader.readAsDataURL(file); - reader.onloadend = () => { const updatedOptionList = optionList.map(optionItem => { if (optionItem.id === optionId) { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 45324606d..57dad3cb1 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import ReactGA from 'react-ga4'; import ReactDOM from 'react-dom/client'; @@ -16,6 +17,10 @@ if ( alert('이 브라우저는 지원 중단 되었습니다. 최적의 환경을 위해 브라우저를 업데이트 하세요.'); } +if (process.env.VOTOGETHER_GOOGLE_ANALYTICS_ID) { + ReactGA.initialize(process.env.VOTOGETHER_GOOGLE_ANALYTICS_ID); +} + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( diff --git a/frontend/src/pages/Announcement/index.tsx b/frontend/src/pages/Announcement/index.tsx index afa24c6e1..aa3807a98 100644 --- a/frontend/src/pages/Announcement/index.tsx +++ b/frontend/src/pages/Announcement/index.tsx @@ -57,8 +57,8 @@ export default function Announcement() {



- (이벤트 기간은 9월 13일(수)~9월 26일(화)이며, 상품 수령 대상자 명단은 공지사항 - 카테고리에서 확인 가능합니다🙂) + (이벤트 기간은 9월 14일(목)~9월 26일(화)이며, 상품 수령 대상자 명단은 공지사항 + 페이지에서 확인 가능합니다🙂) import('@pages/auth/Login')); +const RegisterPersonalInfo = lazy(() => import('@pages/user/RegisterPersonalInfo')); +const VoteStatisticsPage = lazy(() => import('@pages/VoteStatisticsPage')); + const router = createBrowserRouter([ { path: PATH.HOME, @@ -27,6 +30,7 @@ const router = createBrowserRouter([ + ), errorElement: , @@ -37,6 +41,7 @@ const router = createBrowserRouter([ + ), }, @@ -44,7 +49,12 @@ const router = createBrowserRouter([ }, { path: PATH.LOGIN, - element: , + element: ( + <> + + + + ), errorElement: , }, { @@ -62,6 +72,7 @@ const router = createBrowserRouter([ + ), }, @@ -71,6 +82,7 @@ const router = createBrowserRouter([ + ), }, @@ -80,6 +92,7 @@ const router = createBrowserRouter([ + ), }, @@ -89,6 +102,7 @@ const router = createBrowserRouter([ + ), }, @@ -98,6 +112,7 @@ const router = createBrowserRouter([ + ), }, @@ -113,6 +128,7 @@ const router = createBrowserRouter([ + ), }, @@ -122,6 +138,7 @@ const router = createBrowserRouter([ + ), }, @@ -131,6 +148,7 @@ const router = createBrowserRouter([ + ), }, @@ -142,12 +160,22 @@ const router = createBrowserRouter([ }, { path: PATH.RANKING, - element: , + element: ( + <> + + + + ), errorElement: , }, { path: PATH.ANNOUNCEMENT, - element: , + element: ( + <> + + + + ), errorElement: , }, { diff --git a/frontend/src/utils/post/formatContentLink.tsx b/frontend/src/utils/post/formatContentLink.tsx new file mode 100644 index 000000000..d3cffa949 --- /dev/null +++ b/frontend/src/utils/post/formatContentLink.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +export const linkifyText = (text: string) => { + const linkPattern = /\[\[([^[\]]+)\]\]/g; + + const parts = text.split(linkPattern); + + const result = parts.map((part, index) => { + if (index % 2 === 1) { + // 링크 + const linkText = part; + const linkUrl = linkText.startsWith('http' || 'https') ? linkText : `https://${linkText}`; + return ( + + {linkText} + + ); + } else { + // 링크가 아닌 문자열 + return {part}; + } + }); + + return renderArrayWithStringsAndElements(result); +}; + +function renderArrayWithStringsAndElements(arr: any[]) { + const renderedArray = arr.map((item, index) => { + if (typeof item === 'string') { + return item; + } else { + return React.cloneElement(item, { key: index }); + } + }); + + return <>{renderedArray}; +} diff --git a/frontend/src/utils/resizeImage.ts b/frontend/src/utils/resizeImage.ts new file mode 100644 index 000000000..383db9a9a --- /dev/null +++ b/frontend/src/utils/resizeImage.ts @@ -0,0 +1,17 @@ +import imageCompression from 'browser-image-compression'; + +export const convertImageToWebP = async (imageFile: File) => { + const compressedBlob = await imageCompression(imageFile, { + maxWidthOrHeight: 1280, + initialQuality: 0.5, + fileType: 'image/webp', + }); + + const outputWebpFile = new File([compressedBlob], `${Date.now().toString()}.webp`); + + const dataTransfer = new DataTransfer(); + + dataTransfer.items.add(outputWebpFile); + + return dataTransfer.files; +}; diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 8c02bd7d1..7ac442629 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -11,7 +11,7 @@ module.exports = { mode: 'development', entry: './src/index.tsx', output: { - filename: 'bundle.js', + filename: '[contenthash].bundle.js', path: path.resolve(__dirname, 'dist'), clean: true, publicPath: '/',