From c3b990fab5d250879215a94d6757efa51cb0fe1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=98=84=EC=A7=80?= <96380950+kanglocal@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:17:13 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=95=88=EC=AA=BD=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=8D=BC=EB=B8=94?= =?UTF-8?q?=EB=A6=AC=EC=8B=B1=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Design : 리스트 상세페이지 내부의 rankList 컴포넌트 레이아웃 생성 * Design : 리스트 상세페이지 내부의 rankList 컴포넌트 스타일 적용 * Fix : 리스트 상세페이지 내부의 rankList 컴포넌트 스타일 및 타입 수정 * Design : 리스트 상세페이지 내부의 rankList 컴포넌트 스타일 수정 * Design : 리스트 상세페이지 내부의 rankList 컴포넌트에 바텀시트 추가 * Chore : open-graph 라이브러리 설치 * Chore : open-graph 라이브러리 타입 설치 * Chore : cheerio 라이브러리 설치 * feat : 링크 미리보기 기능 일부 구현 (백엔드와 협의 필요) * style : 링크 미리보기 코드 수정 * Design : rankList 스타일 변경 * Feature : 비디오 임베드 추가 및 스타일 변경 * Style : 주석 수정 * Chore : copy-to-clipboard 라이브러리 다운로드 * Feature : 링크 다운로드 기능 구현 * Chore : react-toastify 다운로드 * Feature : 토스트 구현 * Chore : html-to-image 라이브러리 다운로드 * Feature : 리스트상세의 이미지 저장 기능 구현 초안 * Feature : 카카오톡 공유기 초안 * Feature : OG태그 불러오는 로직을 프록시로 이동 * Fix : 목데이터 제거 및 코드 정리 * Fix : 프록시 설정 개발중이기에 localhost:3000 으로 적었으나 이후 배포서버도메인으로 수정하겠습니다. * Fix : 컴포넌트명 변경 추후 Dropdown 컴포넌트가 생길것으로 예상되어 SelectComponent 로 변경했습니다. * Fix : 컴포넌트명 변경 추후 Dropdown 컴포넌트가 생길것으로 예상되어 SelectComponent 로 변경했습니다. * Style : css파일 컨벤션에 맞춰 수정 및 폴더 구조 변경 * fix : layout.tsx에 카카오 sdk, toastContainer 추가 * fix : 카카오톡 공유 템플릿 변경 * fix : 링크 미리보기 수정 일부 og:url 태그가 없는 경우 링크 이동이 안되는 것을 방지하기 위해 인자로받은 linkUrl 사용하도록 수정 * Design : rankList css 수정 * fix : img태그 -> Image로 수정 * Chore : yarn.lock 커밋 * fix : img태그 -> Image태그 * fix : 사용하지 않는 파일 삭제 * fix : Image -> img * Design : 나현님 css에맞춰 수정 * Style : 불필요한 console.log 삭제 * Style : import 순서 변경 * Style : CI/CD에서 잡아준 에러로 수정했습니다. * Style : TODO 주석 생성 * Style : CI/CD 에러 수정 * Style : 오타 수정 * Style : TODO 주석 생성 * Style : CI/CD에러 수정 --- package.json | 9 + public/icons/check_red.svg | 4 +- public/icons/collect.svg | 3 + public/icons/crown.svg | 4 + public/icons/etc.svg | 3 + public/icons/share.svg | 3 + .../BottomSheet/BottomSheet.css.tsx | 74 ++++++++ .../_components/ListDetailInner/Footer.css.ts | 23 +++ .../_components/ListDetailInner/Footer.tsx | 143 +++++++++++++++ .../_components/ListDetailInner/Header.css.ts | 9 + .../_components/ListDetailInner/Header.tsx | 31 ++++ .../ListDetailInner/RankList.css.ts | 163 ++++++++++++++++++ .../_components/ListDetailInner/RankList.tsx | 113 ++++++++++++ .../_components/ListDetailInner/index.css.ts | 25 +++ .../_components/ListDetailInner/index.tsx | 72 ++++++++ .../_components/ListDetailOuter/Header.tsx | 2 +- src/app/create/_components/CreateList.tsx | 8 +- src/app/layout.tsx | 21 ++- .../KakaotalkShare/kakaotalkShare.tsx | 46 +++++ src/components/LinkPreview/LinkPreview.tsx | 53 ++++++ src/components/LinkPreview/style.css.ts | 51 ++++++ .../SelectComponent/SelectComponent.tsx | 65 +++++++ src/components/VideoEmbed/VideoEmbed.tsx | 72 ++++++++ src/components/VideoEmbed/style.css.ts | 17 ++ src/lib/utils/copyUrl.ts | 13 ++ src/lib/utils/saveImageFromHtml.ts | 31 ++++ src/lib/utils/toasting.ts | 23 +++ src/pages/api/getOgDataProxy.ts | 35 ++++ yarn.lock | 158 ++++++++++++++++- 29 files changed, 1264 insertions(+), 10 deletions(-) create mode 100644 public/icons/collect.svg create mode 100644 public/icons/crown.svg create mode 100644 public/icons/etc.svg create mode 100644 public/icons/share.svg create mode 100644 src/app/[userNickname]/[listId]/_components/BottomSheet/BottomSheet.css.tsx create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.css.ts create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.tsx create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.css.ts create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.tsx create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.css.ts create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.tsx create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/index.css.ts create mode 100644 src/app/[userNickname]/[listId]/_components/ListDetailInner/index.tsx create mode 100644 src/components/KakaotalkShare/kakaotalkShare.tsx create mode 100644 src/components/LinkPreview/LinkPreview.tsx create mode 100644 src/components/LinkPreview/style.css.ts create mode 100644 src/components/SelectComponent/SelectComponent.tsx create mode 100644 src/components/VideoEmbed/VideoEmbed.tsx create mode 100644 src/components/VideoEmbed/style.css.ts create mode 100644 src/lib/utils/copyUrl.ts create mode 100644 src/lib/utils/saveImageFromHtml.ts create mode 100644 src/lib/utils/toasting.ts create mode 100644 src/pages/api/getOgDataProxy.ts diff --git a/package.json b/package.json index 605f0417..83628230 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "temp", "version": "0.1.0", "private": true, + "proxy": "http://localhost:3000", "scripts": { "dev": "next dev", "build": "next build", @@ -31,12 +32,19 @@ "@vanilla-extract/next-plugin": "^2.3.2", "@yaireo/tagify": "^4.19.0", "axios": "^1.6.5", + "cheerio": "^1.0.0-rc.12", + "copy-to-clipboard": "^3.3.3", + "html-to-image": "^1.11.11", + "http-proxy-middleware": "^2.0.6", "next": "14.0.4", + "open-graph": "^0.2.6", "react": "^18", "react-beautiful-dnd": "^13.1.1", "react-dom": "^18", "react-hook-form": "^7.50.0", "react-scripts": "^5.0.1", + "react-select": "^5.8.0", + "react-toastify": "^10.0.4", "zustand": "^4.4.7" }, "devDependencies": { @@ -51,6 +59,7 @@ "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/jest": "^29.5.11", "@types/node": "^20", + "@types/open-graph": "^0.2.5", "@types/react": "^18", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18", diff --git a/public/icons/check_red.svg b/public/icons/check_red.svg index ed672394..a5f194df 100644 --- a/public/icons/check_red.svg +++ b/public/icons/check_red.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/public/icons/collect.svg b/public/icons/collect.svg new file mode 100644 index 00000000..06f5d2b0 --- /dev/null +++ b/public/icons/collect.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/crown.svg b/public/icons/crown.svg new file mode 100644 index 00000000..f5e3e5c2 --- /dev/null +++ b/public/icons/crown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/etc.svg b/public/icons/etc.svg new file mode 100644 index 00000000..9c2c3720 --- /dev/null +++ b/public/icons/etc.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/share.svg b/public/icons/share.svg new file mode 100644 index 00000000..c4a8a674 --- /dev/null +++ b/public/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/[userNickname]/[listId]/_components/BottomSheet/BottomSheet.css.tsx b/src/app/[userNickname]/[listId]/_components/BottomSheet/BottomSheet.css.tsx new file mode 100644 index 00000000..e2734537 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/BottomSheet/BottomSheet.css.tsx @@ -0,0 +1,74 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +export const backGround = style({ + position: 'fixed', + top: 0, + left: 0, + bottom: 0, + right: 0, + background: 'rgba(0,0,0,0.3)', + zIndex: 999, +}); + +export const wrapper = style({ + padding: '37px 0 43px', + + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + + backgroundColor: '#ffffff', + borderTopLeftRadius: '25px', + borderTopRightRadius: '25px', + + transitionProperty: 'all', + transitionDuration: '0.2s', +}); + +const slideIn = keyframes({ + from: { transform: 'translateY(100%)' }, + to: { transform: 'translateY(0)' }, +}); + +export const sheetActive = style({ + animation: `${slideIn} 0.2s ease-in-out`, +}); + +export const sheetItemWrapper = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + + ':hover': { + backgroundColor: '#EFEFF0', + }, +}); + +export const checkIcon = style({ + display: 'none', + marginRight: '28px', + + selectors: { + [`${sheetItemWrapper}:hover &`]: { + display: 'block', + }, + }, +}); + +export const sheetItem = style({ + width: '100%', + fontSize: '1.4rem', + cursor: 'pointer', + padding: '2.5rem 2.8rem 2.5rem', + + selectors: { + [`${sheetItemWrapper}:hover &`]: { + color: '#FF5454', + }, + }, +}); diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.css.ts b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.css.ts new file mode 100644 index 00000000..1413050f --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.css.ts @@ -0,0 +1,23 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const shareAndOthers = style({ + width: '100%', + + display: 'flex', + flexDirection: 'row', + justifyContent: 'right', + alignItems: 'center', + gap: '20px', +}); + +export const buttonComponent = style({ + cursor: 'pointer', +}); diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.tsx b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.tsx new file mode 100644 index 00000000..9efa9ce8 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Footer.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { MouseEvent, useState } from 'react'; +import BottomSheet from '@/app/[userNickname]/[listId]/_components/BottomSheet/BottomSheet'; +import ModalPortal from '@/components/ModalPortal'; +import saveImageFromHtml from '@/lib/utils/saveImageFromHtml'; +import copyUrl from '@/lib/utils/copyUrl'; +import toasting from '@/lib/utils/toasting'; +import kakaotalkShare from '@/components/KakaotalkShare/kakaotalkShare'; +import * as styles from './Footer.css'; +import CollectIcon from '/public/icons/collect.svg'; +import ShareIcon from '/public/icons/share.svg'; +import EtcIcon from '/public/icons/etc.svg'; + +interface BottomSheetOptionsProps { + key: string; + title: string; + onClick: () => void; +} + +interface SheetTypeProps { + type: 'share' | 'etc'; +} + +interface FooterProps { + category: string; + listId: string; + title: string; + description: string; + items: []; + collaborators: []; + ownerNickname: string; +} + +function Footer({ data }: { data: FooterProps }) { + const router = useRouter(); + const params = useParams<{ userNickname: string; listId: string }>(); + + const [isSheetActive, setSheetActive] = useState(false); + const [sheetOptionList, setSheetOptionList] = useState([]); + + const handleSheetOptionList = ({ type }: SheetTypeProps) => { + const listUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/${params?.userNickname}/${params?.listId}`; + + if (type === 'share') { + const optionList = [ + { + key: 'copyLink', + title: '리스트 링크 복사하기', + onClick: () => { + copyUrl(listUrl); + setSheetActive(false); + }, + }, + { + key: 'kakaoShare', + title: '리스트 카카오톡으로 공유하기', + onClick: () => { + // TODO: image로 저장한다음에 해당 image를 보내줘야한다. + kakaotalkShare({ + title: data.title, + description: data.description, + image: + 'https://i.namu.wiki/i/-8Iah6PGZzzQuY1KtJIbj8_KBbX4whnbaq8AYShoqphdJOpfJDskZZ2Y3bU2I5Jpnx8aRi1LXTz1_e0v_fMrp172modjOmKRcxcME5dmM6IDAIgqktw5yIs75is2CgC1GrGoxZPwxpeTXudKIxWn2w.webp', + listItem: data.items, + collaborators: data.collaborators, + listId: data.listId, + userNickname: data.ownerNickname, + }); + setSheetActive(false); + }, + }, + ]; + setSheetOptionList([...optionList]); + return; + } + + if (type === 'etc') { + const optionList = [ + { + key: 'saveToImg', + title: '리스트 이미지로 저장하기', + onClick: () => { + setSheetActive(false); + saveImageFromHtml({ filename: `${data.category}_${data.listId}` }); + }, + }, + { + key: 'copyAndCreateList', + title: '이 리스트 템플릿으로 바로 리스트 작성하기', + onClick: () => { + toasting({ type: 'default', txt: '리스트 작성 페이지로 이동합니다.' }); + router.push(`/create?title=${data.title}&category=${data.category}`); + }, + }, + ]; + setSheetOptionList([...optionList]); + return; + } + }; + + const handleSheetActive = ({ type }: SheetTypeProps) => { + handleSheetOptionList({ type }); + setSheetActive((prev: boolean) => !prev); + }; + + const handleOutsideClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) { + setSheetActive(false); + } + }; + + // TODO: 콜렉트 API생성되면 요청보내고 UI변경시키기 + const handleCollect = () => { + console.log('콜렉트기능 미구현'); + }; + + return ( + <> + {isSheetActive && ( + + + + )} + + + + + + handleSheetActive({ type: 'share' })}> + + + handleSheetActive({ type: 'etc' })}> + + + + + > + ); +} + +export default Footer; diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.css.ts b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.css.ts new file mode 100644 index 00000000..c0b4b806 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.css.ts @@ -0,0 +1,9 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + + display: 'flex', + justifyContent: 'right', + alignItems: 'center', +}); diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.tsx b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.tsx new file mode 100644 index 00000000..5b23ed65 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/Header.tsx @@ -0,0 +1,31 @@ +import * as styles from './Header.css'; +import SelectComponent from '@/components/SelectComponent/SelectComponent'; + +interface OptionsProps { + value: string; + label: string; +} + +interface HeaderProps { + handleChangeListType: (target: OptionsProps) => void | undefined; +} + +const dropdownOptions = [ + { + value: 'simple', + label: '간단히', + }, + { + value: 'detail', + label: '자세히', + }, +]; +function Header({ handleChangeListType }: HeaderProps) { + return ( + + + + ); +} + +export default Header; diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.css.ts b/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.css.ts new file mode 100644 index 00000000..4217e9e7 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.css.ts @@ -0,0 +1,163 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + display: 'flex', + justifyContent: 'left', + alignItems: 'center', +}); + +export const listWrapper = style({ + width: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + gap: '1.6rem', + alignItems: 'left', +}); + +export const simpleItemWrapper = style({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + gap: '4rem', + alignItems: 'center', +}); + +export const detailItemWrapper = style({ + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: '1.6rem', + alignItems: 'left', + marginBottom: '6rem', + ':last-child': { + marginBottom: 0, + }, +}); + +export const commentText = style({ + width: '100%', + backgroundColor: 'white', + padding: '2rem', + border: '1px solid #EFEFF0', + borderRadius: '10px', + fontSize: '1.4rem', +}); + +export const rankAndTitle = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + gap: '2rem', +}); + +export const rankTextWrapper = style({ + width: '40px', + height: '40px', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + + fontSize: '2.4rem', + fontWeight: 'bold', + textAlign: 'center', +}); + +export const firstRankTextWrapper = style({ + width: '40px', + height: '40px', + + position: 'relative', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + + backgroundColor: '#0047FF', + borderRadius: '99px', + + fontSize: '2.4rem', + fontWeight: 'bold', + color: 'white', + textAlign: 'center', +}); + +export const crownIcon = style({ + position: 'absolute', + bottom: '40px', +}); + +export const top3RankTextWrapper = style({ + width: '40px', + height: '40px', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + + backgroundColor: '#EBF4FF', + borderRadius: '99px', + + fontSize: '2.4rem', + fontWeight: 'bold', + color: '#0047FF', + textAlign: 'center', +}); + +export const rankText = style({ + position: 'relative', + top: '2px', +}); + +export const titleText = style({ + fontSize: '2rem', +}); + +export const simpleImageWrapper = style({ + width: '7rem', + height: '7rem', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + textAlign: 'center', +}); + +export const detailImageWrapper = style({ + width: '100%', + height: 'auto', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + textAlign: 'center', +}); + +export const simpleImage = style({ + width: '7rem', + height: '7rem', + + borderRadius: '10px', + boxShadow: '0px 8px 15px rgba(0, 0, 0, 0.15)', + + objectFit: 'cover', +}); + +export const detailImage = style({ + width: '100%', + maxHeight: '35rem', + height: 'auto', + + border: '1px solid #EFEFF0', + borderRadius: '10px', + + objectFit: 'cover', +}); diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.tsx b/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.tsx new file mode 100644 index 00000000..469e865d --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/RankList.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { ListItemProps } from './index'; +import LinkPreview from '@/components/LinkPreview/LinkPreview'; +import VideoEmbed from '@/components/VideoEmbed/VideoEmbed'; +import * as styles from './RankList.css'; +import CrownIcon from '/public/icons/crown.svg'; + +interface RankListProps { + listData: ListItemProps[]; + type?: string; +} + +function SimpleList({ listData }: RankListProps) { + return listData.map((item, index) => { + return ( + + + + {index === 0 && } + {item.rank} + + {item.title} + + + {item.imageUrl && } + + + ); + }); +} + +function EmbedComponent({ link }: { link: string }) { + let linkType = ''; + // 일반url(link), 비디오(video), 지도(map) 로 구분하기. 지금은 비디오랑 링크만 구분. + // TODO: 지도 추가하기 + const isVideoLink = link.includes('youtube.com') || link.includes('youtu.be') || link.includes('vimeo.com'); + const isMapLink = false; + + if (isVideoLink) { + linkType = 'video'; + } else if (isMapLink) { + linkType = 'map'; + } else { + linkType = 'link'; + } + + if (linkType === 'link') { + return LinkPreview(link); + } + + if (linkType === 'video') { + return ; + } +} +function DetailList({ listData }: RankListProps) { + return listData.map((item, index) => { + return ( + + + + {index === 0 && } + {item.rank} + + {item.title} + + {item.comment} + + {item.imageUrl && ( + + )} + + {item.link && } + + ); + }); +} + +function RankList({ listData, type }: RankListProps) { + return ( + + + {listData ? ( + type == 'simple' ? ( + + ) : ( + + ) + ) : ( + 데이터가 없습니다. + )} + + + ); +} + +export default RankList; diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.css.ts b/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.css.ts new file mode 100644 index 00000000..da701df4 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.css.ts @@ -0,0 +1,25 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: '0.2rem', +}); + +export const listAndFooter = style({ + width: '100%', + padding: '4rem 4rem 2rem 4rem', + + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: '4rem', + + borderTop: '1px solid #D9D9D9', + borderBottom: '1px solid #D9D9D9', +}); diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.tsx b/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.tsx new file mode 100644 index 00000000..44b3a6b6 --- /dev/null +++ b/src/app/[userNickname]/[listId]/_components/ListDetailInner/index.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import Header from '@/app/[userNickname]/[listId]/_components/ListDetailInner/Header'; +import RankList from '@/app/[userNickname]/[listId]/_components/ListDetailInner/RankList'; +import Footer from '@/app/[userNickname]/[listId]/_components/ListDetailInner/Footer'; +import * as styles from './index.css'; + +export interface ListItemProps { + id?: number; + rank?: number; + title?: string; + comment?: string; + link?: string | null; + imageUrl?: string | null; +} + +interface OptionsProps { + value: string; + label: string; +} + +interface ListDetailInnerProps { + listId: string; + category: string; + labels: []; + title: string; + description: string; + createdDate: string; + lastUpdatedDate: string; + ownerId: number; + ownerNickname: string; + ownerProfileImageUrl: string; + collaborators: []; + items: []; + isCollected: boolean; + isPublic: boolean; + backgroundColor: string; + collectCount: number; + viewCount: number; +} + +function ListDetailInner({ data }: { data: ListDetailInnerProps }) { + const listData = data.items; + const [listType, setListType] = useState('simple'); + const handleChangeListType = (target: OptionsProps) => { + const value: string = target.value; + setListType(value); + }; + + const footerData = { + category: data.category, + listId: data.listId, + title: data.title, + description: data.description, + items: listData, + collaborators: data.collaborators, + ownerNickname: data.ownerNickname, + }; + + return ( + + + + + + + + ); +} + +export default ListDetailInner; diff --git a/src/app/[userNickname]/[listId]/_components/ListDetailOuter/Header.tsx b/src/app/[userNickname]/[listId]/_components/ListDetailOuter/Header.tsx index 2e12cc4d..09b3b5b6 100644 --- a/src/app/[userNickname]/[listId]/_components/ListDetailOuter/Header.tsx +++ b/src/app/[userNickname]/[listId]/_components/ListDetailOuter/Header.tsx @@ -14,7 +14,7 @@ function Header() { }; const handleHistoryButtonClick = () => { - router.push(`/${params.userNickname}/${params.listId}/history`); + router.push(`/${params?.userNickname}/${params?.listId}/history`); }; return ( diff --git a/src/app/create/_components/CreateList.tsx b/src/app/create/_components/CreateList.tsx index 2366d99c..ca8b3a19 100644 --- a/src/app/create/_components/CreateList.tsx +++ b/src/app/create/_components/CreateList.tsx @@ -44,7 +44,7 @@ function CreateList({ onNextClick }: CreateListProps) { const collaboIDs = useWatch({ control, name: 'collaboratorIds' }); const searchParams = useSearchParams(); - const isTemplateCreation = searchParams.has('title') && searchParams.has('category'); + const isTemplateCreation = searchParams?.has('title') && searchParams?.has('category'); const fetchUsers = async () => { try { @@ -65,8 +65,8 @@ function CreateList({ onNextClick }: CreateListProps) { const handleQueryParams = () => { if (isTemplateCreation) { - setValue('title', searchParams.get('title')); - setValue('category', searchParams.get('category')); + setValue('title', searchParams?.get('title')); + setValue('category', searchParams?.get('category')); } }; handleQueryParams(); @@ -100,7 +100,7 @@ function CreateList({ onNextClick }: CreateListProps) { onClick={(item: CategoryType) => { setValue('category', item.nameValue); }} - defaultValue={searchParams.get('category')} + defaultValue={searchParams?.get('category')} /> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4908be29..ca44e808 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,19 +3,38 @@ import { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@/styles/GlobalStyles.css'; +import Script from 'next/script'; +import { ToastContainer } from 'react-toastify'; const queryClient = new QueryClient(); - +declare global { + interface Window { + Kakao: any; + } +} export default function TempLayout({ children }: { children: ReactNode }) { + function kakaoInit() { + window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_API_KEY); + window.Kakao.isInitialized(); + } + return ( ListyWave + + {children} + diff --git a/src/components/KakaotalkShare/kakaotalkShare.tsx b/src/components/KakaotalkShare/kakaotalkShare.tsx new file mode 100644 index 00000000..5e37bca5 --- /dev/null +++ b/src/components/KakaotalkShare/kakaotalkShare.tsx @@ -0,0 +1,46 @@ +'use client'; + +interface kakaotalkShareProps { + title: string; + description: string; + image?: string; + listItem?: { title: string }[]; + collaborators: []; + userNickname: string; + listId: string; +} +function kakaotalkShare({ + title, + description, + image, + listItem = [], + collaborators, + userNickname, + listId, +}: kakaotalkShareProps) { + const itemTitle1 = listItem[0]?.title ?? ''; + const itemTitle2 = listItem[1]?.title ?? ''; + const itemTitle3 = listItem[2]?.title ?? ''; + let allWriter = ''; + if (collaborators) { + allWriter = [userNickname, ...collaborators].join(','); + } else { + allWriter = userNickname; + } + + window.Kakao.Share.sendCustom({ + templateId: 103935, + templateArgs: { + title: title, + description: description, + userNickname: userNickname, + listId: listId, + itemTitle1, + itemTitle2, + itemTitle3, + allWriter, + }, + }); +} + +export default kakaotalkShare; diff --git a/src/components/LinkPreview/LinkPreview.tsx b/src/components/LinkPreview/LinkPreview.tsx new file mode 100644 index 00000000..a5100bbb --- /dev/null +++ b/src/components/LinkPreview/LinkPreview.tsx @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; +import * as styles from '@/components/LinkPreview/style.css'; + +async function fetchLinkPreviewData(url: string) { + try { + // TODO: axios.get 사용시 에러발생 원인 파악 + const response = await fetch(`/api/getOgDataProxy?url=${encodeURIComponent(url)}`); + + const data = await response.json(); + + return { + title: data.title, + description: data.description, + image: data.image, + url: data.url, + }; + } catch (error) { + console.error('url정보를 가져오는데 실패했습니다.', error); + return {}; + } +} + +const LinkPreview = (linkUrl: string) => { + const { data, isSuccess, isFetching } = useQuery({ + queryKey: ['linkPreview' + linkUrl], + queryFn: () => fetchLinkPreviewData(linkUrl), + }); + + if (isFetching) { + return 로딩중입니다.; + } + + if (isSuccess && data) { + const { url, title, description, image } = data; + + return ( + + + {data.image && } + + {title || '제목이 없습니다.'} + {description || '설명이 없습니다.'} + {url} + + + + ); + } + + return 미리보기를 가져오는데 실패했습니다.; +}; + +export default LinkPreview; diff --git a/src/components/LinkPreview/style.css.ts b/src/components/LinkPreview/style.css.ts new file mode 100644 index 00000000..93611cee --- /dev/null +++ b/src/components/LinkPreview/style.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + + color: 'black', +}); + +export const wrapper = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + + border: '1px solid lightgray', + borderRadius: '10px', + + overflow: 'hidden', +}); + +export const image = style({ + width: '100%', + minHeight: '5rem', + + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '1rem', + + backgroundColor: 'lightgray', +}); + +export const contentWrapper = style({ + padding: '1rem', + + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + gap: '0.5rem', +}); + +export const title = style({}); + +export const description = style({ + color: 'gray', +}); + +export const url = style({ + color: 'lightgray', +}); diff --git a/src/components/SelectComponent/SelectComponent.tsx b/src/components/SelectComponent/SelectComponent.tsx new file mode 100644 index 00000000..bc3f86be --- /dev/null +++ b/src/components/SelectComponent/SelectComponent.tsx @@ -0,0 +1,65 @@ +import Select from 'react-select'; + +interface OptionsProps { + value: string; + label: string; +} + +interface SelectProps { + name: string; + options: OptionsProps[]; + isSearchable?: boolean; + onChange?: any; +} + +const selectStyles = { + control: (provided: object, state: { isFocused: boolean }) => ({ + ...provided, + maxWidth: '200px', + backgroundColor: 'white', + boxShadow: 'none', + border: 0, + borderRadius: '8px', + cursor: 'pointer', + '&:hover': { + borderColor: 'none', + backgroundColor: 'lightgray', + }, + }), + singleValue: (provided: object) => ({ + ...provided, + fontSize: '1.3rem', + }), + option: (provided: object, { isFocused, isSelected }: { isFocused: boolean; isSelected: boolean }) => ({ + ...provided, + backgroundColor: isSelected || isFocused ? 'lightgray' : 'white', + color: 'black', + fontSize: '1.3rem', + }), + menu: (provided: object) => ({ + ...provided, + maxWidth: '320px', + boxShadow: '0px 2px 12px 0px rgba(0, 0, 0, 0.08)', + borderRadius: '8px', + border: `1px solid gray`, + }), +}; + +function SelectComponent({ name, options, isSearchable = false, onChange }: SelectProps) { + return ( + null, + }} + /> + ); +} + +export default SelectComponent; diff --git a/src/components/VideoEmbed/VideoEmbed.tsx b/src/components/VideoEmbed/VideoEmbed.tsx new file mode 100644 index 00000000..d8018038 --- /dev/null +++ b/src/components/VideoEmbed/VideoEmbed.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; +import * as styles from '@/components/VideoEmbed/style.css'; + +interface VideoEmbedProps { + videoUrl: string; +} + +function VideoEmbed({ videoUrl }: VideoEmbedProps) { + const [embedCode, setEmbedCode] = useState(null); + + useEffect(() => { + if (isYouTubeLink(videoUrl)) { + embedYouTubeVideo(videoUrl); + } else if (isVimeoLink(videoUrl)) { + embedVimeoVideo(videoUrl); + } else { + console.log('지원하지 않는 플랫폼입니다.'); + } + }, [videoUrl]); + + const isYouTubeLink = (url: string) => { + return url.includes('youtube.com') || url.includes('youtu.be'); + }; + + const isVimeoLink = (url: string) => { + return url.includes('vimeo.com'); + }; + + const embedYouTubeVideo = (url: string) => { + const videoId = getYoutubeVideoId(url); + if (videoId) { + setEmbedCode( + `` + ); + } else { + console.log('유효하지 않은 url입니다.'); + } + }; + + const embedVimeoVideo = (url: string) => { + const videoId = getVimeoVideoId(url); + if (videoId) { + setEmbedCode( + `` + ); + } else { + console.log('유효하지 않은 url입니다.'); + } + }; + + const getYoutubeVideoId = (url: string) => { + const regExp = + /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; + const match = url.match(regExp); + return match && match[1] ? match[1] : null; + }; + + const getVimeoVideoId = (url: string) => { + const regExp = /vimeo\.com\/(?:.*\/)?([0-9]+)/; + const match = url.match(regExp); + return match && match[1] ? match[1] : null; + }; + + // TODO: XSS이슈 대비로 수정 예정(Dompurify 등 라이브러리 사용) + return ( + + + + ); +} + +export default VideoEmbed; diff --git a/src/components/VideoEmbed/style.css.ts b/src/components/VideoEmbed/style.css.ts new file mode 100644 index 00000000..4c6a4517 --- /dev/null +++ b/src/components/VideoEmbed/style.css.ts @@ -0,0 +1,17 @@ +import { style } from '@vanilla-extract/css'; + +export const videoWrapper = style({ + border: '1px solid #EFEFF0', + borderRadius: '10px', + + overflow: 'hidden', +}); + +export const videoFrame = style({ + width: '100%', + height: 'auto', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); diff --git a/src/lib/utils/copyUrl.ts b/src/lib/utils/copyUrl.ts new file mode 100644 index 00000000..2adbea9d --- /dev/null +++ b/src/lib/utils/copyUrl.ts @@ -0,0 +1,13 @@ +import copy from 'copy-to-clipboard'; +import toasting from '@/lib/utils/toasting'; + +function copyUrl(listUrl: string) { + try { + copy(listUrl); + toasting({ type: 'default', txt: '링크가 복사되었습니다.' }); + } catch (error) { + toasting({ type: 'default', txt: '링크 복사를 실패했습니다.' }); + } +} + +export default copyUrl; diff --git a/src/lib/utils/saveImageFromHtml.ts b/src/lib/utils/saveImageFromHtml.ts new file mode 100644 index 00000000..47922f63 --- /dev/null +++ b/src/lib/utils/saveImageFromHtml.ts @@ -0,0 +1,31 @@ +import { toPng } from 'html-to-image'; +import toasting from '@/lib/utils/toasting'; + +interface saveImageFromHtmlProps { + filename: string; +} +async function saveImageFromHtml({ filename }: saveImageFromHtmlProps) { + const saveElement: HTMLElement | null = document.querySelector('#rankList'); + if (!saveElement) { + console.error('리스트를 찾을 수 없습니다.'); + return; + } + try { + toPng(saveElement) + .then((dataUrl) => { + const link = document.createElement('a'); + link.download = filename + '.png'; + link.href = dataUrl; + link.click(); + }) + .catch((err) => { + console.log('error', err); + }); + // TODO: 토스트가 아닌 모달로 변경해야함. 이미지도 함께 넣어야한다. + toasting({ type: 'default', txt: '이미지를 저장했습니다.' }); + } catch (error) { + toasting({ type: 'default', txt: '이미지 저장을 실패했습니다.' }); + } +} + +export default saveImageFromHtml; diff --git a/src/lib/utils/toasting.ts b/src/lib/utils/toasting.ts new file mode 100644 index 00000000..44cb4e01 --- /dev/null +++ b/src/lib/utils/toasting.ts @@ -0,0 +1,23 @@ +import { toast, ToastOptions } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +function toasting({ type = 'default', txt = '' }) { + const toastOption: ToastOptions = { + position: 'bottom-center', + autoClose: 1000, + hideProgressBar: true, + closeOnClick: true, + pauseOnHover: false, + draggable: false, + progress: undefined, + theme: 'light', + }; + + if (type !== ('success' || 'error' || 'warning')) { + toast(txt, toastOption); + } else { + toast[type](txt, toastOption); + } +} + +export default toasting; diff --git a/src/pages/api/getOgDataProxy.ts b/src/pages/api/getOgDataProxy.ts new file mode 100644 index 00000000..cbc54751 --- /dev/null +++ b/src/pages/api/getOgDataProxy.ts @@ -0,0 +1,35 @@ +import axios, { AxiosResponse } from 'axios'; +import cheerio from 'cheerio'; +import { NextApiRequest, NextApiResponse } from 'next'; + +interface OpenGraphData { + [key: string]: string; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { url } = req.query; + + if (!url || typeof url !== 'string') { + return res.status(400).json({ message: 'URL의 쿼리파라미터가 없습니다.' }); + } + + try { + const { data }: AxiosResponse = await axios.get(url); + const $ = cheerio.load(data); + const ogData: OpenGraphData = {}; + + $('meta').each((_, element) => { + if ($(element).attr('property')?.includes('og:')) { + const property = $(element).attr('property')?.replace('og:', '') ?? ''; + const content = $(element).attr('content') ?? ''; + + ogData[property] = content; + } + }); + + res.status(200).json(ogData); + } catch (error) { + console.error(error); + res.status(500).json({ message: '데이터를 가져오는데 실패했습니다.' }); + } +} diff --git a/yarn.lock b/yarn.lock index b51caa7a..bc97089a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -183,7 +183,7 @@ dependencies: "@babel/types" "^7.23.0" -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.22.15": +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== @@ -1127,6 +1127,13 @@ integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": + version "7.23.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" + integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/runtime@^7.12.0", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== @@ -1530,11 +1537,119 @@ dependencies: "@egjs/grid" "~1.15.0" -"@emotion/hash@^0.9.0": +"@emotion/babel-plugin@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" + integrity sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/serialize" "^1.1.2" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + +"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + +"@emotion/hash@^0.9.0", "@emotion/hash@^0.9.1": version "0.9.1" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43" integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + +"@emotion/react@^11.8.1": + version "11.11.3" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.3.tgz#96b855dc40a2a55f52a72f518a41db4f69c31a25" + integrity sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/cache" "^11.11.0" + "@emotion/serialize" "^1.1.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + hoist-non-react-statics "^3.3.1" + +"@emotion/serialize@^1.1.2", "@emotion/serialize@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.3.tgz#84b77bfcfe3b7bb47d326602f640ccfcacd5ffb0" + integrity sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA== + dependencies: + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/unitless" "^0.8.1" + "@emotion/utils" "^1.2.1" + csstype "^3.0.2" + +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + +"@emotion/unitless@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + +"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963" + integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== + +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + +"@esbuild/android-arm64@0.17.6": + version "0.17.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.6.tgz#b11bd4e4d031bb320c93c83c137797b2be5b403b" + integrity sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg== + +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + +"@esbuild/android-arm@0.17.6": + version "0.17.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.6.tgz#ac6b5674da2149997f6306b3314dae59bbe0ac26" + integrity sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g== + +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + +"@esbuild/android-x64@0.17.6": + version "0.17.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.6.tgz#18c48bf949046638fc209409ff684c6bb35a5462" + integrity sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ== + "@esbuild/aix-ppc64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" @@ -4571,6 +4686,20 @@ cookie@0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +copy-to-clipboard@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" + integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA== + dependencies: + toggle-selection "^1.0.6" + +core-js-compat@^3.31.0, core-js-compat@^3.33.1: + version "3.35.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.35.0.tgz#c149a3d1ab51e743bc1da61e39cb51f461a41873" + integrity sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw== + dependencies: + browserslist "^4.22.2" + core-js-compat@^3.31.0, core-js-compat@^3.34.0: version "3.35.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.35.1.tgz#215247d7edb9e830efa4218ff719beb2803555e2" @@ -4588,6 +4717,11 @@ core-js@^3.19.2: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.35.1.tgz#9c28f8b7ccee482796f8590cc8d15739eaaf980c" integrity sha512-IgdsbxNyMskrTFxa9lWHyMwAJU5gXOPP+1yO+K59d50VLVAIDAbs7gIv705KzALModfK3ZrSZTPNpC0PQgIZuw== +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -11752,6 +11886,26 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vite-node@^0.28.5: + version "0.28.5" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.28.5.tgz#56d0f78846ea40fddf2e28390899df52a4738006" + integrity sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA== + dependencies: + cac "^6.7.14" + debug "^4.3.4" + pathe "^1.1.1" + picocolors "^1.0.0" + vite "^5.0.0" + vite-node@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.2.2.tgz#f6d329b06f9032130ae6eac1dc773f3663903c25"
{description || '설명이 없습니다.'}
{url}