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 && img설명} +
+
+ ); + }); +} + +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.title}" + )} +
+ {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 + +