diff --git a/.gitignore b/.gitignore index 8f322f0d8..fd3dbb571 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.yarn/install-state.gz # testing /coverage diff --git a/README.md b/README.md index a75ac5248..d6bbf996f 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,56 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +## Styles + +### Week7 + +1. 상단 네비게이션 바, 푸터를 랜딩 페이지와 동일한 스타일과 규칙으로 만듭니다. (week 1 ~ 3 요구사항 참고) +2. Static, no image, Hover 상태 디자인을 보여주는 카드 컴포넌트를 만듭니다. +3. Hover 상태에서 이미지가 기본 크기의 130%로 변합니다. +4. 카드 컴포넌트를 클릭하면 해당 링크로 새로운 창을 띄워서 이동합니다. +5. Tablet에서 카드 리스트가 화면의 너비 1124px를 기준으로 이상일 때는 3열로 작을 때는 2열로 배치됩니다. +6. Tablet, Mobile에서 좌우 최소 여백은 32px 입니다. + +### Week8 + +1. 반응형을 위한 스타일코드들을 리팩토링 합니다. + +## API & Logic + +### Week7 + +1. 상단 네비게이션 바에 프로필 영역의 데이터는 https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sample/user”를 활용합니다. +2. 상단 네비게이션 바에 프로필 영역의 데이터가 없는 경우 “로그인” 버튼이 보이도록 만듭니다. +3. 폴더 소유자, 폴더 이름 영역, 링크들에 대한 데이터는 “/api/sample/folder”를 활용합니다. +4. 커스텀 hook을 만들어 사용합니다.(선택) +5. shared 페이지는 외부 유저에게 자신의 폴더 데이터 하나를 공유할 때 유저가 보게되는 화면 입니다. +6. 카드 컴포넌트에서 createdAt 데이터 기준으로 현재 Date와 차이에 따라 아래와 같은 규칙으로 설정해 주세요. + - 2분 미만은 “1 minute ago” + - 59분 이하는 “OO minutes ago” + - 60분 이상은 “1 hour ago” + - 23시간 이하는 “OO hours ago” + - 24시간 이상은 “1 day ago” + - 30일 이하는 “OO days ago” + - 31일 이상은 “1 month ago” + - 11달 이하는 “OO months ago” + - 12달 이상은 “1 year ago” + - OO달 이상은 “{OO/12(소수점 버린 정수)} years ago” + +### Week8 + +1. useEffect 훅의 내부로직과 디펜던스를 올바르게 수정합니다. +2. api와 util함수들을 디렉토리를 따로 생성해 관리를 쉽게 합니다. +3. GNB의 네이밍을 더 목적에 맞게 수정합니다. +4. 시간경과 함수의 하드코딩이 된 부분을 더 명확하고 깔끔하게 수정합니다. +5. 시간경과 함수의 조건문을 수정합니다. + +### Week13 + +1. 기존의 프로젝트를 nextjs로 마이그레이션 합니다. +2. 폴더페이지에서 아이디가 있는 폴더로 변경했을 때 전체폴더로 돌아갈 수 없는 문제를 해결합니다. +3. 검색바의 기능을 구현합니다. +4. 로그인, 회원가입에 필요한 인풋을 구현합니다. +5. 기존의 react-router-dom으로 구현했던 쿼리파라미터 관련 로직을 next의 useRouter로 변경합니다. + +### Week14 + +1. 소셜공유 로직을 분리합니다. +2. 모달 컴포넌트를 더 명확하고 간단하게 리팩토링합니다. diff --git a/components/Avatar/Avatar.module.css b/components/Avatar/Avatar.module.css new file mode 100644 index 000000000..aa9b32c23 --- /dev/null +++ b/components/Avatar/Avatar.module.css @@ -0,0 +1,18 @@ +.avatar { + position: relative; + margin-right: 6px; +} + +.avatar img { + border-radius: 100%; +} + +.small { + width: 35px; + height: 35px; +} + +.medium { + width: 100px; + height: 100px; +} diff --git a/components/Avatar/index.tsx b/components/Avatar/index.tsx new file mode 100644 index 000000000..68f45553e --- /dev/null +++ b/components/Avatar/index.tsx @@ -0,0 +1,23 @@ +import Image from "next/image"; +import classNames from "classnames"; +import styles from "@/components/Avatar/Avatar.module.css"; + +interface AvatarProps { + src: string; + size: string; +} + +function Avatar({ src, size }: AvatarProps) { + const avatarClass = classNames(styles.avatar, { + [styles.small]: size === "small", + [styles.medium]: size === "medium", + }); + + return ( +
+ avatar +
+ ); +} + +export default Avatar; diff --git a/components/FolderController/FoldersController.module.css b/components/FolderController/FoldersController.module.css new file mode 100644 index 000000000..a49288f1d --- /dev/null +++ b/components/FolderController/FoldersController.module.css @@ -0,0 +1,16 @@ +.container { + width: 1060px; + margin: 0 auto; +} + +@media (min-width: 768px) and (max-width: 1199px) { + .container { + width: 705px; + } +} + +@media (min-width: 375px) and (max-width: 767px) { + .container { + width: 325px; + } +} diff --git a/components/FolderController/index.tsx b/components/FolderController/index.tsx new file mode 100644 index 000000000..393c0e12b --- /dev/null +++ b/components/FolderController/index.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; +import useAsync from "@/lib/useAsync"; +import { FolderData, LinkData, getLinksByUserIdAndFolderId } from "@/lib/api"; +import SearchBar from "@/components/SearchBar"; +import FoldersList from "@/components/FoldersList"; +import FolderLinkCards from "@/components/FolderLinkCards"; +import styles from "@/components/FolderController/FoldersController.module.css"; + +interface FoldersControllerProps { + folders: FolderData[]; + userId: number; +} + +function FoldersController({ folders, userId }: FoldersControllerProps) { + const [searchedLinks, setSearchedLinks] = useState(null); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const router = useRouter(); + const { + value: links, + isLoading, + error, + } = useAsync( + getLinksByUserIdAndFolderId, + userId, + selectedFolderId + ); + + const handleClickFolder = (folderId: number | null) => { + router.push({ + pathname: router.pathname, + query: { ...router.query, folderId }, + }); + + if (!folderId) { + router.push({ + pathname: router.pathname, + }); + } + + setSelectedFolderId(folderId); + }; + + const handleSearchByKeyword = (keyword: string) => { + if (!links) return console.log("링크가 존재하지 않습니다!"); + const searchedLink = links?.filter((link) => link.title?.includes(keyword)); + if (!searchedLink) return console.log("해당 링크가 존재하지 않습니다!"); + setSearchedLinks(searchedLink); + }; + + return ( +
+ + {isLoading ? ( +
Loading...
+ ) : error ? ( +
Error loading data.
+ ) : ( + links && ( + <> + + + + ) + )} +
+ ); +} + +export default FoldersController; diff --git a/components/FolderLinkCard/index.tsx b/components/FolderLinkCard/index.tsx new file mode 100644 index 000000000..48375d90b --- /dev/null +++ b/components/FolderLinkCard/index.tsx @@ -0,0 +1,94 @@ +import { ReactElement, useState } from "react"; +import Image from "next/image"; +import { LinkData } from "@/lib/api"; +import { displayCreatedTime, formatDateString } from "@/lib/dateUtils"; +import Modal from "@/components/Modal"; +import styles from "@/components/LinkCard.module.css"; +import { FaRegStar } from "react-icons/fa"; +import { GoKebabHorizontal } from "react-icons/go"; +import LinkDeleteForm from "../Modal/childrens/LinkDeleteForm"; +import LinkAddToFolderForm from "../Modal/childrens/LinkAddToFolderForm"; + +interface FolderLinkCardProps { + link: LinkData; +} + +interface ActionTypes { + [actionType: string]: ReactElement; +} + +function FolderLinkCard({ link }: FolderLinkCardProps) { + const [onModal, setOnModal] = useState(false); + const [modalContent, setModalContent] = useState(null); + + const handleClickModal = (actionType: string) => { + const actionTypes: ActionTypes = { + deleteLink: , + addLink: , + }; + + setModalContent(actionTypes[actionType]); + setOnModal(true); + }; + + const { url, description, title, created_at, image_source } = link; + + const createdTime = displayCreatedTime(created_at); + const createdAtFormat = formatDateString(created_at); + + const src = image_source || "/card-default.png"; + + const handleToggleDropDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const button = e.currentTarget; + const linkInfo = button.closest(`.${styles.linkInfo}`); + const dropdown = linkInfo?.querySelector(`.${styles.dropdown}`); + + dropdown?.classList.toggle(styles.hidden); + }; + + return ( +
+ +
+
+ {title +
+ +
+
+
+
+
{createdTime}
+ +
+
{description}
+
{createdAtFormat}
+
+ + +
+
+ {onModal && ( + setOnModal(false)}>{modalContent} + )} +
+ ); +} + +export default FolderLinkCard; diff --git a/components/FolderLinkCards/index.tsx b/components/FolderLinkCards/index.tsx new file mode 100644 index 000000000..2dd13c429 --- /dev/null +++ b/components/FolderLinkCards/index.tsx @@ -0,0 +1,32 @@ +import { LinkData } from "@/lib/api"; +import FolderLinkCard from "@/components/FolderLinkCard"; +import styles from "@/components/LinkCards.module.css"; + +interface FolderLinkCardsProps { + links: LinkData[]; + searchedLinks: LinkData[] | null; +} + +function FolderLinkCards({ links, searchedLinks }: FolderLinkCardsProps) { + return ( + <> + {links.length > 0 ? ( +
+
    + {(searchedLinks ? searchedLinks : links).map((link) => ( +
  • + +
  • + ))} +
+
+ ) : ( +
+ 저장된 링크가 없습니다. +
+ )} + + ); +} + +export default FolderLinkCards; diff --git a/components/FoldersList/FoldersList.module.css b/components/FoldersList/FoldersList.module.css new file mode 100644 index 000000000..ef3d1ec49 --- /dev/null +++ b/components/FoldersList/FoldersList.module.css @@ -0,0 +1,86 @@ +.container { + display: flex; + flex-direction: column; + width: 1060px; +} + +.listContainer { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 20px; + position: relative; +} + +.folderButtonList { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.folderButtonList button { + flex-shrink: 0; + background-color: var(--white); + border-radius: 8px; + border: 1px solid var(--primary); + padding: 8px 12px 8px 12px; + text-align: center; +} + +.folderAddButton { + width: 75px; + border: none; + background: none; + color: var(--primary); +} + +.controlContainer { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.selectedFolderName { + font-size: 24px; + font-weight: 600; +} + +.folderControl { + display: flex; + gap: 3px; +} + +.folderControl button { + display: flex; + align-items: center; + background-color: transparent; + border: none; + gap: 2px; +} + +@media (min-width: 768px) and (max-width: 1199px) { + .container { + width: 705px; + } +} + +@media (min-width: 375px) and (max-width: 767px) { + .container { + width: 325px; + } + + .folderAddButton { + width: 130px; + height: 35px; + padding: 8px 24px; + color: var(--white); + background-color: var(--primary); + position: absolute; + bottom: -500px; + left: 95px; + border-radius: 20px; + } +} diff --git a/components/FoldersList/index.tsx b/components/FoldersList/index.tsx new file mode 100644 index 000000000..c91210f57 --- /dev/null +++ b/components/FoldersList/index.tsx @@ -0,0 +1,92 @@ +import { ReactElement, useState } from "react"; +import { FolderData } from "@/lib/api"; +import Modal from "@/components/Modal"; +import { FaPencilAlt, FaRegShareSquare, FaRegTrashAlt } from "react-icons/fa"; +import styles from "@/components/FoldersList/FoldersList.module.css"; +import FolderAddForm from "../Modal/childrens/FolderAddForm"; +import SocialShareBox from "../Modal/childrens/SocialShareBox"; +import FolderEditForm from "../Modal/childrens/FolderEditForm"; +import FolderDeleteForm from "../Modal/childrens/FolderDeleteForm"; + +interface FoldersListProps { + handleClick: (folderId: number | null) => void; + folders: FolderData[]; + selectedFolderId: number | null; +} + +interface ActionTypes { + [actionType: string]: ReactElement; +} + +function FoldersList({ + handleClick, + folders, + selectedFolderId, +}: FoldersListProps) { + const [modalContent, setModalContent] = useState(null); + const currentFolder = folders.find( + (folder) => folder.id === selectedFolderId + ); + + const handleClickModal = (actionType: string) => { + const actionTypes: ActionTypes = { + add: , + share: , + modify: , + delete: , + }; + + setModalContent(actionTypes[actionType]); + }; + + return ( +
+
+
    +
  • + +
  • + {folders.map((folder) => ( +
  • + +
  • + ))} +
+ +
+
+
+ {currentFolder ? currentFolder.name : "전체"} +
+ {selectedFolderId && ( +
+ + + +
+ )} +
+ {modalContent && ( + setModalContent(null)}>{modalContent} + )} +
+ ); +} + +export default FoldersList; diff --git a/components/Footer/Footer.module.css b/components/Footer/Footer.module.css new file mode 100644 index 000000000..883ee1c37 --- /dev/null +++ b/components/Footer/Footer.module.css @@ -0,0 +1,62 @@ +.container { + width: 100%; + display: grid; + grid-template: auto / 1fr 1fr 1fr; + padding: 40px 104px; + margin-top: 70px; + background-color: var(--black); + color: var(--white); +} + +.container a { + display: inline-block; + text-decoration: none; + color: var(--white); +} + +.companyInfo { + display: flex; + align-items: center; +} + +.forUser { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + +.socialMedia { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +@media (min-width: 375px) and (max-width: 767px) { + .container { + padding: 30px 60px; + grid-template: + "b c" 90px + "a ." 20px/ 1fr 1fr; + } + + .forUser { + font-size: 14px; + grid-area: b; + justify-content: flex-start; + align-items: flex-start; + } + + .companyInfo { + font-size: 14px; + grid-area: a; + } + + .socialMedia { + grid-area: c; + } + + .socialMedia a { + height: 16px; + } +} diff --git a/components/Footer/index.tsx b/components/Footer/index.tsx new file mode 100644 index 000000000..64b497464 --- /dev/null +++ b/components/Footer/index.tsx @@ -0,0 +1,36 @@ +import { FaFacebook, FaInstagram, FaTwitter, FaYoutube } from "react-icons/fa"; +import styles from "@/components/Footer/Footer.module.css"; + +function Footer() { + return ( + + ); +} + +export default Footer; diff --git a/components/Header/Header.module.css b/components/Header/Header.module.css new file mode 100644 index 000000000..17493e2e9 --- /dev/null +++ b/components/Header/Header.module.css @@ -0,0 +1,56 @@ +.container { + width: 100%; + height: 110px; + position: fixed; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 200px; + z-index: 1; +} + +.logo { + width: 133px; + height: 24px; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.loginBtn { + font-size: 22px; + font-weight: 600; +} + +.profileContainer { + display: flex; + align-items: center; + position: relative; +} + +.profileContainer span { + display: inline-block; + font-size: 16px; +} + +@media (min-width: 768px) and (max-width: 1199px) { + .container { + padding: 0 32px; + } +} + +@media (min-width: 375px) and (max-width: 767px) { + .container { + padding: 0 32px; + } + + .logo { + width: 100px; + } + + .profileContainer span { + display: none; + } +} diff --git a/components/Header/index.tsx b/components/Header/index.tsx new file mode 100644 index 000000000..296944bcf --- /dev/null +++ b/components/Header/index.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import Avatar from "@/components/Avatar"; +import styles from "@/components/Header/Header.module.css"; + +interface HeaderProps { + userAvatarImage: string; + userProfileEmail: string; + userLogInSuccess: boolean; +} + +function Header({ + userAvatarImage, + userProfileEmail, + userLogInSuccess, +}: HeaderProps) { + return ( +
+
+ logo +
+ {userLogInSuccess ? ( +
+ + {userProfileEmail} +
+ ) : ( + + )} +
+ ); +} + +export default Header; diff --git a/components/Input/Input.module.css b/components/Input/Input.module.css new file mode 100644 index 000000000..f1cf630ad --- /dev/null +++ b/components/Input/Input.module.css @@ -0,0 +1,35 @@ +.container { + width: 350px; + position: relative; +} + +.inputWrapper { + width: 100%; + padding: 18px 15px; + border: 1px solid var(--gray-500); + border-radius: 8px; +} + +.inputWrapper:focus { + border: 1px solid var(--primary); +} + +.eyeSlash { + border: none; + background: none; + position: absolute; + right: 16px; + top: 17px; +} + +.errorBorder { + border: 1px solid var(--red); +} + +.errorMessage { + display: inline-block; + font-size: 14px; + font-weight: 400; + color: var(--red); + margin-top: 5px; +} diff --git a/components/Input/index.tsx b/components/Input/index.tsx new file mode 100644 index 000000000..96cb5a394 --- /dev/null +++ b/components/Input/index.tsx @@ -0,0 +1,41 @@ +import { HTMLInputTypeAttribute, useEffect, useState } from "react"; +import { FaEyeSlash } from "react-icons/fa"; +import styles from "@/components/Input/Input.module.css"; + +interface InputProps { + type: HTMLInputTypeAttribute; +} + +// 추후에 벨리데이션 로직과 컴포넌트에서 사용할 때 리팩토링 예정 +function Input({ type }: InputProps) { + const [isError, setIsError] = useState(true); + const inputBorder = document.querySelector(".inputWrapper"); + + return ( +
+ {isError ? ( + <> + + 내용을 다시 작성해주세요 + + ) : ( + + )} + {type === "password" && ( + + )} +
+ ); +} + +export default Input; diff --git a/components/LinkAddForm/LinkAddForm.module.css b/components/LinkAddForm/LinkAddForm.module.css new file mode 100644 index 000000000..1e5a63823 --- /dev/null +++ b/components/LinkAddForm/LinkAddForm.module.css @@ -0,0 +1,55 @@ +.container { + width: 100%; + padding: 140px 0 80px; + background-color: var(--gray-300); + display: flex; + justify-content: center; +} + +.form { + width: 800px; + height: 70px; + position: relative; + display: flex; +} + +.linkIcon { + position: absolute; + top: 26px; + left: 15px; + color: var(--gray-600); +} + +.input { + width: 100%; + padding: 16px 40px 16px 40px; + border: 1px solid var(--primary); + border-radius: 15px; + color: var(--gray-600); +} + +.button { + width: 80px; + height: 35px; + position: absolute; + top: 17px; + right: 15px; + border-radius: 8px; + border: none; + background: var(--primary-gradient); + color: var(--white); + font-size: 14px; + font-weight: 600; +} + +@media (min-width: 768px) and (max-width: 1199px) { + .form { + width: 705px; + } +} + +@media (min-width: 375px) and (max-width: 767px) { + .form { + width: 325px; + } +} diff --git a/components/LinkAddForm/index.tsx b/components/LinkAddForm/index.tsx new file mode 100644 index 000000000..c42fd0f9a --- /dev/null +++ b/components/LinkAddForm/index.tsx @@ -0,0 +1,20 @@ +import { FaLink } from "react-icons/fa"; +import styles from "@/components/LinkAddForm/LinkAddForm.module.css"; + +function LinkAddForm() { + return ( +
+
+ + + + +
+ ); +} + +export default LinkAddForm; diff --git a/components/LinkCard.module.css b/components/LinkCard.module.css new file mode 100644 index 000000000..50c91055a --- /dev/null +++ b/components/LinkCard.module.css @@ -0,0 +1,120 @@ +.linkContainer { + width: 340px; + border: none; + border-radius: 15px; + box-shadow: 0 0 15px 2px var(--gray-400); +} + +.imageWrapper { + width: 100%; + height: 200px; + border-radius: 15px 15px 0 0; + position: relative; + overflow: hidden; +} + +.linkImage { + border-radius: 15px 15px 0 0; + width: 340px; + height: 100%; + transition: transform 0.3s ease; + position: relative; +} + +.linkImage:hover { + transform: scale(1.3); +} + +.linkInfo { + padding: 15px; + height: 135px; + border-radius: 0 0 15px 15px; + display: flex; + flex-direction: column; + justify-content: center; + transition: background-color 0.3s ease; + position: relative; +} + +.linkContainer:hover .linkInfo { + background-color: var(--gray-400); +} + +.linkInfoContent:first-child { + font-size: 13px; + font-weight: 400; + color: var(--gray-600); + display: flex; + justify-content: space-between; + align-items: center; +} + +.linkInfoContent:nth-child(2) { + font-size: 16px; + font-weight: 400; + color: var(--black); + margin: 10px 0 10px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + height: 48px; + line-height: 1.5; +} + +.linkInfoContent:nth-child(3) { + font-size: 14px; + font-weight: 400; + color: var(--gray-700); +} + +.starIcon { + color: var(--gray-600); + width: 30px; + height: 30px; + position: absolute; + right: 15px; + top: 15px; +} + +.kebabButton { + background-color: transparent; + border: none; +} + +.hidden { + display: none; +} + +.dropdown { + width: 100px; + position: absolute; + right: -55px; + top: 35px; + z-index: 100; +} + +.dropdown button { + padding: 7px 12px; + width: 100px; + border: none; + box-shadow: 0 0 15px 2px var(--gray-400); + cursor: pointer; +} + +.dropdown button:first-child { + background-color: var(--white); +} + +.dropdown button:nth-child(2) { + background-color: var(--gray-400); +} + +@media (min-width: 375px) and (max-width: 767px) { + .container, + .linkImageAnchor, + .linkImage { + width: 325px; + } +} diff --git a/components/LinkCards.module.css b/components/LinkCards.module.css new file mode 100644 index 000000000..8c6dc1cab --- /dev/null +++ b/components/LinkCards.module.css @@ -0,0 +1,40 @@ +.container { + width: 1060px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.linkList { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 20px; +} + +.empty { + min-height: 400px; + display: flex; + justify-content: center; +} + +@media (min-width: 768px) and (max-width: 1199px) { + .container { + width: 705px; + } + + .linkList { + grid-template-columns: 1fr 1fr; + } +} + +@media (min-width: 375px) and (max-width: 767px) { + .container { + width: 325px; + } + + .linkList { + grid-template-columns: 1fr; + } +} diff --git a/components/Modal/Modal.module.css b/components/Modal/Modal.module.css new file mode 100644 index 000000000..8139d4bff --- /dev/null +++ b/components/Modal/Modal.module.css @@ -0,0 +1,36 @@ +.background { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1000; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + position: fixed; +} + +.container { + padding: 28px; + background-color: var(--white); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + border-radius: 15px; + position: relative; +} + +.exitButton { + width: 24px; + height: 24px; + position: absolute; + right: 16px; + top: 16px; + border: none; + border-radius: 9999px; + background-color: var(--gray-400); + color: var(--gray-600); + cursor: pointer; +} diff --git a/components/Modal/childrens/FolderAddForm/index.tsx b/components/Modal/childrens/FolderAddForm/index.tsx new file mode 100644 index 000000000..d46671576 --- /dev/null +++ b/components/Modal/childrens/FolderAddForm/index.tsx @@ -0,0 +1,19 @@ +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +function FolderAddForm() { + return ( + <> +

폴더 추가

+
+ + +
+ + ); +} + +export default FolderAddForm; diff --git a/components/Modal/childrens/FolderDeleteForm/index.tsx b/components/Modal/childrens/FolderDeleteForm/index.tsx new file mode 100644 index 000000000..6620fa446 --- /dev/null +++ b/components/Modal/childrens/FolderDeleteForm/index.tsx @@ -0,0 +1,17 @@ +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +function FolderDeleteForm() { + return ( + <> +

폴더 삭제

+

폴더명

+
+ +
+ + ); +} + +export default FolderDeleteForm; diff --git a/components/Modal/childrens/FolderEditForm/index.tsx b/components/Modal/childrens/FolderEditForm/index.tsx new file mode 100644 index 000000000..a14794d4d --- /dev/null +++ b/components/Modal/childrens/FolderEditForm/index.tsx @@ -0,0 +1,19 @@ +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +function FolderEditForm() { + return ( + <> +

폴더 이름 변경

+
+ + +
+ + ); +} + +export default FolderEditForm; diff --git a/components/Modal/childrens/LinkAddToFolderForm/index.tsx b/components/Modal/childrens/LinkAddToFolderForm/index.tsx new file mode 100644 index 000000000..bf9877ff3 --- /dev/null +++ b/components/Modal/childrens/LinkAddToFolderForm/index.tsx @@ -0,0 +1,29 @@ +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +function LinkAddToFolderForm() { + return ( + <> +

폴더에 추가

+

링크 주소

+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+ + ); +} + +export default LinkAddToFolderForm; diff --git a/components/Modal/childrens/LinkDeleteForm/index.tsx b/components/Modal/childrens/LinkDeleteForm/index.tsx new file mode 100644 index 000000000..07bfd6ef7 --- /dev/null +++ b/components/Modal/childrens/LinkDeleteForm/index.tsx @@ -0,0 +1,17 @@ +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +function LinkDeleteForm() { + return ( + <> +

링크 삭제

+

url

+
+ +
+ + ); +} + +export default LinkDeleteForm; diff --git a/components/Modal/childrens/ModalChildren.module.css b/components/Modal/childrens/ModalChildren.module.css new file mode 100644 index 000000000..befba1571 --- /dev/null +++ b/components/Modal/childrens/ModalChildren.module.css @@ -0,0 +1,92 @@ +.formContainer { + width: 280px; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} + +.formInput { + width: 100%; + padding: 15px 18px; + border: 1px solid var(--primary); + border-radius: 10px; + color: var(--black); + font-size: 16px; + font-weight: 400; +} + +.formButton { + width: 100%; + padding: 16px 20px; + border-radius: 10px; + border: none; + background: var(--primary-gradient); + font-size: 16px; + font-weight: 600; + color: var(--white); +} + +.redBackground { + background: none; + background-color: var(--red); +} + +.shareBox { + width: 280px; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + margin-top: 10px; +} + +.shareButtonWrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 400; + color: var(--black); +} + +.shareButton { + background: none; + border: none; + border-radius: 100%; +} + +.title { + font-size: 20px; + font-weight: 700; + color: var(--black); +} + +.subTitle { + font-size: 14px; + font-weight: 400; + color: var(--gray-600); +} + +.inputList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.inputList button { + width: 100%; + text-align: left; + font-size: 16px; + font-weight: 400; + background-color: transparent; + border: none; + border-radius: 8px; + padding: 6px 15px; +} + +.inputList button:active { + background-color: var(--gray-400); + color: var(--primary); +} diff --git a/components/Modal/childrens/SocialShareBox/index.tsx b/components/Modal/childrens/SocialShareBox/index.tsx new file mode 100644 index 000000000..430a0c065 --- /dev/null +++ b/components/Modal/childrens/SocialShareBox/index.tsx @@ -0,0 +1,92 @@ +import Image from "next/image"; +import styles from "@/components/Modal/childrens/ModalChildren.module.css"; + +interface SocialShareBoxProps { + title: string; +} + +function SocialShareBox({ title }: SocialShareBoxProps) { + /* const location = useLocation(); + const currentUrl = + window.location.origin + location.pathname + location.search; + const facebookShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent( + currentUrl + )}`; + + useEffect(() => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }, []); + + const handleCopyToClipBoard = async () => { + try { + await navigator.clipboard.writeText(currentUrl); + alert("클립보드에 복사되었습니다!"); + } catch (error) { + console.error("클립보드 복사 실패:", error); + alert("클립보드 복사에 실패했습니다."); + } + }; + + const handleShareToKakao = (title: string) => { + window.Kakao.Link.sendDefault({ + objectType: "feed", + content: { + title: title, + description: "유용한 링크를 모은 폴더를 공유합니다.", + imageUrl: defaultImg, + link: { + mobileWebUrl: window.location.href, + webUrl: window.location.href, + }, + }, + buttons: [ + { + title: "웹으로 보기", + link: { + mobileWebUrl: window.location.href, + webUrl: window.location.href, + }, + }, + ], + }); + }; */ + + return ( + <> +

폴더 공유

+

폴더명

+
+
+ + 카카오톡 +
+
+ + + + 페이스북 +
+
+ + 링크 복사 +
+
+ + ); +} + +export default SocialShareBox; diff --git a/components/Modal/index.tsx b/components/Modal/index.tsx new file mode 100644 index 000000000..1264a58a9 --- /dev/null +++ b/components/Modal/index.tsx @@ -0,0 +1,21 @@ +import styles from "@/components/Modal/Modal.module.css"; + +interface ModalProps { + children: React.ReactNode; + onClick: () => void; +} + +function Modal({ children, onClick }: ModalProps) { + return ( +
+
+ {children} + +
+
+ ); +} + +export default Modal; diff --git a/components/SearchBar/SearchBar.module.css b/components/SearchBar/SearchBar.module.css new file mode 100644 index 000000000..d0cbe33f1 --- /dev/null +++ b/components/SearchBar/SearchBar.module.css @@ -0,0 +1,26 @@ +.wrapper { + width: 100%; + height: 54px; + padding: 15px 16px 15px 16px; + margin: 50px 0; + border-radius: 10px; + background-color: var(--gray-500); + display: flex; + align-items: center; +} + +.button, +.input { + border: none; + background-color: transparent; + color: var(--gray-700); + font-size: 16px; +} + +.input { + width: 100%; +} + +.input:focus { + outline: none; +} diff --git a/components/SearchBar/index.tsx b/components/SearchBar/index.tsx new file mode 100644 index 000000000..df7c6c4e9 --- /dev/null +++ b/components/SearchBar/index.tsx @@ -0,0 +1,38 @@ +import { FaSearch } from "react-icons/fa"; +import styles from "@/components/SearchBar/SearchBar.module.css"; +import { ChangeEventHandler, FormEventHandler, useState } from "react"; + +interface SearchBarProps { + onSearch: (keyword: string) => void; +} + +function SearchBar({ onSearch }: SearchBarProps) { + const [inputValue, setInputValue] = useState(""); + + const handleChange: ChangeEventHandler = (e) => { + setInputValue(e.target.value); + }; + + const handleSubmit: FormEventHandler = (e) => { + e.preventDefault(); + + onSearch(inputValue); + }; + + return ( +
+ + +
+ ); +} + +export default SearchBar; diff --git a/components/SharedLinkCard/index.tsx b/components/SharedLinkCard/index.tsx new file mode 100644 index 000000000..f752ec514 --- /dev/null +++ b/components/SharedLinkCard/index.tsx @@ -0,0 +1,48 @@ +import { SampleLink } from "@/lib/api"; +import { displayCreatedTime, formatDateString } from "@/lib/dateUtils"; +import styles from "@/components/LinkCard.module.css"; +import Image from "next/image"; + +interface SharedLinkCardProps { + link: SampleLink; +} + +function SharedLinkCard({ link }: SharedLinkCardProps) { + const { url, description, title, createdAt, imageSource } = link; + + const createdTime = displayCreatedTime(createdAt); + const createdAtFormat = formatDateString(createdAt); + + const src = imageSource || "/card-default.png"; + + return ( +
+ +
+
+ {title +
+
+
+
+
{createdTime}
+
{description}
+
{createdAtFormat}
+
+
+ ); +} + +export default SharedLinkCard; diff --git a/components/SharedLinkCards/SharedLinkCards.tsx b/components/SharedLinkCards/SharedLinkCards.tsx new file mode 100644 index 000000000..ba44a48d5 --- /dev/null +++ b/components/SharedLinkCards/SharedLinkCards.tsx @@ -0,0 +1,37 @@ +import { SampleLink } from "@/lib/api"; +import SearchBar from "@/components/SearchBar"; +import SharedLinkCard from "@/components/SharedLinkCard"; +import styles from "@/components/LinkCards.module.css"; +import { useState } from "react"; + +interface SharedLinkCardsProps { + links: SampleLink[]; +} + +function SharedLinkCards({ links }: SharedLinkCardsProps) { + const [searchedLinks, setSearchedLinks] = useState(null); + + const handleSearchByKeyword = (keyword: string) => { + if (!links) return console.log("링크가 존재하지 않습니다!"); + const searchedLink = links?.filter((link) => + link.description?.toLowerCase().includes(keyword.toLowerCase()) + ); + if (!searchedLink) return console.log("해당 링크가 존재하지 않습니다!"); + setSearchedLinks(searchedLink); + }; + + return ( +
+ +
    + {(searchedLinks ? searchedLinks : links).map((link) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export default SharedLinkCards; diff --git a/components/UserProfileAndTitle/UserProfileAndTitle.module.css b/components/UserProfileAndTitle/UserProfileAndTitle.module.css new file mode 100644 index 000000000..b5b866129 --- /dev/null +++ b/components/UserProfileAndTitle/UserProfileAndTitle.module.css @@ -0,0 +1,18 @@ +.container { + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + background-color: var(--gray-300); + padding: 140px 0 80px; +} + +.userName { + font-size: 16px; +} + +.folderName { + font-size: 40px; +} diff --git a/components/UserProfileAndTitle/index.tsx b/components/UserProfileAndTitle/index.tsx new file mode 100644 index 000000000..2b0c4f54a --- /dev/null +++ b/components/UserProfileAndTitle/index.tsx @@ -0,0 +1,24 @@ +import Avatar from "@/components/Avatar"; +import styles from "@/components/UserProfileAndTitle/UserProfileAndTitle.module.css"; + +interface UserProfileAndTitleProps { + userName: string; + folderName: string; + folderImage: string; +} + +function UserProfileAndTitle({ + userName, + folderName, + folderImage, +}: UserProfileAndTitleProps) { + return ( +
+ +
@{userName}
+
{folderName}
+
+ ); +} + +export default UserProfileAndTitle; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 000000000..dce83d704 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,140 @@ +import { Params } from "./useAsync"; + +const BASE_URL = "https://bootcamp-api.codeit.kr/api"; + +export interface SampleUser { + id: number; + name: string; + email: string; + profileImageSource: string; +} + +interface SampleFolderOwner { + id: number; + name: string; + profileImageSource: string; +} + +export interface SampleLink { + id: number; + createdAt: string; + url: string; + title: string; + description: string; + imageSource: string; +} + +export interface SampleFolder { + id: number; + name: string; + owner: SampleFolderOwner; + links?: SampleLink[]; +} + +interface SampleFolderResponse { + folder: SampleFolder; +} + +export interface UserData { + id: number; + created_at: string; + name: string; + image_source: string; + email: string; + auth_id: string; +} + +export interface FolderData { + id: number; + created_at: string; + name: string; + user_id: number; + favorite: boolean; + link: { + count: number; + }; +} + +export interface LinkData { + id: number; + created_at: string; + updated_at?: string; + url: string; + title: string; + description?: string; + image_source?: string; + folder_id: number; +} + +interface Response { + data: Data[]; +} + +export async function getUser(): Promise { + const response = await fetch(`${BASE_URL}/sample/user`); + if (!response.ok) { + throw new Error("잘못된 요청입니다."); + } + + const user: SampleUser = await response.json(); + + return user; +} + +export async function getFolder(): Promise { + const response = await fetch(`${BASE_URL}/sample/folder`); + if (!response.ok) { + throw new Error("잘못된 요청입니다."); + } + + const data: SampleFolderResponse = await response.json(); + const { folder } = data; + + return folder; +} + +export async function getUserById({ userId }: Params): Promise { + const response = await fetch(`${BASE_URL}/users/${userId}`); + if (!response.ok) { + throw new Error("잘못된 요청입니다."); + } + + const user: Response = await response.json(); + const { data } = user; + + return data[0]; +} + +export async function getFoldersByUserId({ + userId, +}: Params): Promise { + const response = await fetch(`${BASE_URL}/users/${userId}/folders`); + if (!response.ok) { + throw new Error("잘못된 요청입니다."); + } + + const folders: Response = await response.json(); + const { data } = folders; + + return data; +} + +export async function getLinksByUserIdAndFolderId({ + userId, + folderId, +}: Params): Promise { + let url = `${BASE_URL}/users/${userId}/links`; + if (folderId) { + url += `?folderId=${folderId}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error("잘못된 요청입니다."); + } + + const links: Response = await response.json(); + const { data } = links; + + return data; +} diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 000000000..3b6df320c --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1 @@ +export const KAKAO_KEY = "a841341da0099a5d1291638b48030745"; diff --git a/lib/dateUtils.ts b/lib/dateUtils.ts new file mode 100644 index 000000000..f5e6f37a6 --- /dev/null +++ b/lib/dateUtils.ts @@ -0,0 +1,43 @@ +const MINUTE: number = 60 * 1000; +const HOUR: number = 60 * MINUTE; +const DAY: number = 24 * HOUR; +const MONTH: number = 30 * DAY; +const YEAR: number = 12 * MONTH; + +interface TimeFormat { + unit: number; + text: string; +} + +export function displayCreatedTime(date: string) { + const nowDate = new Date(); + const createdDate = new Date(date); + const diff: number = nowDate.getTime() - createdDate.getTime(); + + const times: TimeFormat[] = [ + { unit: YEAR, text: "년 전" }, + { unit: MONTH, text: "개월 전" }, + { unit: DAY, text: "일 전" }, + { unit: HOUR, text: "시간 전" }, + { unit: MINUTE, text: "분 전" }, + ]; + + for (const { unit, text } of times) { + const diffTime = Math.floor(diff / unit); + if (diffTime !== 0) { + return `${diffTime}${text}`; + } + } + + return "방금 전"; +} + +export function formatDateString(date: string) { + const createdDate = new Date(date); + + const year = createdDate.getFullYear(); + const month = String(createdDate.getMonth() + 1).padStart(2, "0"); + const day = String(createdDate.getDate()).padStart(2, "0"); + + return `${year}. ${month}. ${day}`; +} diff --git a/lib/useAsync.ts b/lib/useAsync.ts new file mode 100644 index 000000000..61a0b6529 --- /dev/null +++ b/lib/useAsync.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; +export interface CustomAsyncReturns { + isLoading: boolean; + value: T | null; + error: Error | null; +} + +export interface Params { + userId?: number; + folderId?: number | null; +} + +type AsyncFunc = (params: Params) => Promise; + +const useAsync = ( + asyncFunc: AsyncFunc, + userId?: Params["userId"], + folderId?: Params["folderId"] +): CustomAsyncReturns => { + const [isLoading, setIsLoading] = useState(false); + const [value, setValue] = useState(null); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setIsLoading(true); + try { + let result; + const params: Params = {}; + if (userId !== undefined) params.userId = userId; + if (folderId !== undefined) params.folderId = folderId; + + result = await asyncFunc(params); + setValue(result); + } catch (error: unknown) { + if (error instanceof Error) { + setError(error); + } + } finally { + setIsLoading(false); + } + }, [asyncFunc, userId, folderId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { value, error, isLoading }; +}; + +export default useAsync; diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 000000000..71ebab852 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*", + port: "", + pathname: "/**", + }, + ], + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json index 1ce24924f..2ede8346d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "fe-weekly-mission", + "name": "nextjs-migration", "version": "0.1.0", "private": true, "scripts": { @@ -11,7 +11,9 @@ "dependencies": { "react": "^18", "react-dom": "^18", - "next": "13.5.6" + "next": "14.2.3", + "classnames": "^2.5.1", + "react-icons": "^5.0.1" }, "devDependencies": { "typescript": "^5", @@ -19,6 +21,6 @@ "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", - "eslint-config-next": "13.5.6" + "eslint-config-next": "14.2.3" } } diff --git a/pages/_app.tsx b/pages/_app.tsx index 021681f4d..15f90f46b 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,6 +1,12 @@ -import '@/styles/globals.css' -import type { AppProps } from 'next/app' +import type { AppProps } from "next/app"; +import "@/styles/global.css"; +import Footer from "@/components/Footer"; export default function App({ Component, pageProps }: AppProps) { - return + return ( + <> + +