diff --git a/components/Folder/AddFolderButton.tsx b/components/Folder/AddFolderButton.tsx new file mode 100644 index 0000000..5f48fb4 --- /dev/null +++ b/components/Folder/AddFolderButton.tsx @@ -0,0 +1,23 @@ +import { FolderData } from "@/types/folderTypes"; +import useModalStore from "@/store/useModalStore"; +import useRerenderFolderList from "@/hooks/useRerenderFolderList"; + +interface AddFolderButtonProps { + setFolderList: React.Dispatch>; +} + +export const AddFolderButton = ({ setFolderList }: AddFolderButtonProps) => { + const { isOpen, openModal } = useModalStore(); + + useRerenderFolderList(isOpen, setFolderList); + + return ( + + ); +}; +export default AddFolderButton; diff --git a/components/Folder/FolderActionsMenu.tsx b/components/Folder/FolderActionsMenu.tsx new file mode 100644 index 0000000..c54dcfd --- /dev/null +++ b/components/Folder/FolderActionsMenu.tsx @@ -0,0 +1,51 @@ +import { FolderData } from "@/types/folderTypes"; +import Image from "next/image"; +import useModalStore from "@/store/useModalStore"; +import useRerenderFolderList from "../../hooks/useRerenderFolderList"; + +interface FolderActionsMenuProps { + setFolderList: React.Dispatch>; +} + +const FolderActionsMenu = ({ setFolderList }: FolderActionsMenuProps) => { + const { isOpen, openModal } = useModalStore(); + + const handleModalOpen = (text: string) => { + switch (text) { + case "공유": + openModal("SNSModal"); + break; + case "이름 변경": + openModal("EditModal"); + break; + case "삭제": + openModal("DeleteFolderModal"); + break; + default: + break; + } + }; + + useRerenderFolderList(isOpen, setFolderList); + + return ( +
+ {[ + { src: "/icons/share.svg", alt: "공유", text: "공유" }, + { src: "/icons/pen.svg", alt: "이름 변경", text: "이름 변경" }, + { src: "/icons/delete.svg", alt: "삭제", text: "삭제" }, + ].map(({ src, alt, text }) => ( + + ))} +
+ ); +}; + +export default FolderActionsMenu; diff --git a/components/FolderTag.tsx b/components/Folder/FolderTag.tsx similarity index 95% rename from components/FolderTag.tsx rename to components/Folder/FolderTag.tsx index 33e91b4..b4768c2 100644 --- a/components/FolderTag.tsx +++ b/components/Folder/FolderTag.tsx @@ -11,7 +11,7 @@ const FolderTag = ({ folderList }: FolderListData) => { const handleSubmit = (id: number | string) => { router.push({ pathname: router.pathname, - query: { ...router.query, folder: id }, + query: id ? { folder: id } : {}, }); }; diff --git a/components/Link/ActionButtons.tsx b/components/Link/ActionButtons.tsx deleted file mode 100644 index b498677..0000000 --- a/components/Link/ActionButtons.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import Image from "next/image"; - -const ActionButtons = () => ( -
- {[ - { src: "/icons/share.svg", alt: "공유", text: "공유" }, - { src: "/icons/pen.svg", alt: "이름 변경", text: "이름 변경" }, - { src: "/icons/delete.svg", alt: "삭제", text: "삭제" }, - ].map(({ src, alt, text }) => ( - - ))} -
-); - -export default ActionButtons; diff --git a/components/Link/AddLinkInput.tsx b/components/Link/AddLinkInput.tsx index add6904..e66fbe5 100644 --- a/components/Link/AddLinkInput.tsx +++ b/components/Link/AddLinkInput.tsx @@ -1,9 +1,12 @@ -import { ChangeEvent, KeyboardEvent, useState } from "react"; +import { ChangeEvent, KeyboardEvent, useEffect, useState } from "react"; import { FolderListData } from "@/types/folderTypes"; +import { Modal } from "../modal/modalManager/ModalManager"; import Image from "next/image"; import SubmitButton from "../SubMitButton"; +import useModalStore from "@/store/useModalStore"; const AddLinkInput = ({ folderList }: FolderListData) => { + const { isOpen, openModal } = useModalStore(); const [link, setLink] = useState(""); const handleChange = (e: ChangeEvent) => { @@ -11,7 +14,8 @@ const AddLinkInput = ({ folderList }: FolderListData) => { }; const handleClick = () => { - // Addmodal 띄우면서 link 전달 + openModal("AddModal", { list: folderList, link: link }); + setLink(""); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -34,6 +38,7 @@ const AddLinkInput = ({ folderList }: FolderListData) => {
추가하기
+ {isOpen && } ); }; diff --git a/components/LinkCard.tsx b/components/Link/LinkCard.tsx similarity index 98% rename from components/LinkCard.tsx rename to components/Link/LinkCard.tsx index ee74346..b813318 100644 --- a/components/LinkCard.tsx +++ b/components/Link/LinkCard.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import timeAgo from "@/util/timAgo"; import Image from "next/image"; -import Dropdown from "./Dropdown"; +import Dropdown from "../Dropdown"; import useModalStore from "@/store/useModalStore"; interface LinkCardProps { diff --git a/components/Pagination.tsx b/components/Pagination.tsx index 858be86..4a6df9f 100644 --- a/components/Pagination.tsx +++ b/components/Pagination.tsx @@ -1,22 +1,21 @@ import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/router"; import { useEffect, useState } from "react"; interface PaginationProps { - page: number; - pageSize: number; totalCount: number; } -const Pagination: React.FC = ({ - page, - pageSize, - totalCount, -}) => { +const Pagination: React.FC = ({ totalCount }) => { + const router = useRouter(); const LiStyle = "relative w-12 h-12 rounded-lg bg-gray900"; const buttonStyle = "flex justify-center items-center h-full text-black400"; + const { page, pageSize } = router.query; - const totalPages = Math.ceil(totalCount / pageSize); + const currentPage = Number(page); + const currentPageSize = Number(pageSize); + const totalPages = Math.ceil(totalCount / currentPageSize); const [maxPagesToShow, setMaxPagesToShow] = useState(2); // 화면 크기 변화에 따라 pageSize와 maxPagesToShow를 설정 @@ -45,12 +44,12 @@ const Pagination: React.FC = ({ } else { // 첫 페이지와 마지막 페이지는 항상 표시 pages.push(1); - let start = Math.max(2, page - 1); - let end = Math.min(totalPages - 1, page + 1); + let start = Math.max(2, currentPage - 1); + let end = Math.min(totalPages - 1, currentPage + 1); - if (page > 3) pages.push("..."); + if (currentPage > 3) pages.push("..."); for (let i = start; i <= end; i++) pages.push(i); - if (page < totalPages - 2) pages.push("..."); + if (currentPage < totalPages - 2) pages.push("..."); pages.push(totalPages); } @@ -61,12 +60,12 @@ const Pagination: React.FC = ({
  • 1 ? "text-black500" : "pointer-events-none"}`} + href={`/link?page=${currentPage - 1}&pageSize=${currentPageSize}`} + className={`${buttonStyle} ${currentPage > 1 ? "text-black500" : "pointer-events-none"}`} > 1 + currentPage > 1 ? "/icons/pagination-left-active.png" : "/icons/pagination-left.png" } @@ -83,7 +82,7 @@ const Pagination: React.FC = ({
  • {pageNum} @@ -100,12 +99,12 @@ const Pagination: React.FC = ({
  • { pathname: router.pathname, query: { ...router.query, search: value }, }); + setValue(""); }; return ( diff --git a/components/Search/SearchResultMessage.tsx b/components/Search/SearchResultMessage.tsx new file mode 100644 index 0000000..4ee0f8e --- /dev/null +++ b/components/Search/SearchResultMessage.tsx @@ -0,0 +1,14 @@ +interface SearchResultMessageProps { + message: string | string[]; +} + +const SearchResultMessage = ({ message }: SearchResultMessageProps) => { + return ( +
    + "{message}"으로 검색한 + 결과입니다. +
    + ); +}; + +export default SearchResultMessage; diff --git a/hooks/useFetchLinks.tsx b/hooks/useFetchLinks.tsx new file mode 100644 index 0000000..c01b4af --- /dev/null +++ b/hooks/useFetchLinks.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { proxy } from "@/lib/api/axiosInstanceApi"; +import { LinkData } from "@/types/linkTypes"; +import useViewport from "./useViewport"; + +// 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅 +const useFetchLinks = ( + query: { + page?: number; + search?: string; + }, + setLinkCardList: (list: LinkData[]) => void +) => { + const { isTablet } = useViewport(); + + useEffect(() => { + const fetchLinks = async () => { + const res = await proxy.get("/api/links", { + params: { + page: query.page, + pageSize: isTablet ? 6 : 10, + search: query.search, + }, + }); + setLinkCardList(res.data.list); + }; + + if (query) fetchLinks(); + }, [query, setLinkCardList, isTablet]); +}; + +export default useFetchLinks; diff --git a/hooks/useRerenderFolderList.tsx b/hooks/useRerenderFolderList.tsx new file mode 100644 index 0000000..1a03701 --- /dev/null +++ b/hooks/useRerenderFolderList.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; +import { getFolders } from "@/lib/api/folder"; +import { FolderData } from "@/types/folderTypes"; + +// 모달이 닫혔을 때 새로운 FolderList를 보여주는 커스텀 훅 +const useRerenderFolderList = ( + isOpen: boolean, + setFolderList: React.Dispatch> +) => { + const isFirstRender = useRef(true); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; // 최초 로드 시에 불필요한 fetch 요청을 막아줌. + } + + const fetchNewFolderList = async () => { + const res = await getFolders(); + setFolderList(res); + }; + + if (!isOpen) { + fetchNewFolderList(); // 드랍다운이 한번 열리고 닫혔을 때 데이터 fetch + } + }, [isOpen, setFolderList]); +}; + +export default useRerenderFolderList; diff --git a/hooks/useViewport.tsx b/hooks/useViewport.tsx new file mode 100644 index 0000000..cd654bd --- /dev/null +++ b/hooks/useViewport.tsx @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react"; + +const breakpoints = { + PC: { min: 1200 }, + Tablet: { min: 768, max: 1199 }, + Mobile: { min: 343, max: 767 }, +}; + +// 현재 브라우저의 innerWidth와 반응형 상태를 반환하는 훅 +function useViewport(initialWidth = 0) { + const [width, setWidth] = useState(initialWidth); + + const handleResize = () => { + setWidth(window.innerWidth); + }; + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const isPC = width >= breakpoints.PC.min; + const isTablet = + width >= breakpoints.Tablet.min && width <= breakpoints.Tablet.max; + const isMobile = + width >= breakpoints.Mobile.min && width <= breakpoints.Mobile.max; + + return { width, isPC, isTablet, isMobile }; +} + +export default useViewport; diff --git a/lib/api/axiosInstanceApi.ts b/lib/api/axiosInstanceApi.ts index 3b15de6..a037316 100644 --- a/lib/api/axiosInstanceApi.ts +++ b/lib/api/axiosInstanceApi.ts @@ -2,14 +2,14 @@ import axios from "axios"; const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000", - timeout: 5000, + timeout: 10000, withCredentials: true, }); export const proxy = axios.create({ // 배포 이후에는 배포된 URL로 변경해야 함. baseURL: "http://localhost:3000", - timeout: 5000, + timeout: 10000, withCredentials: true, }); diff --git a/lib/api/fetchProxy.ts b/lib/api/fetchProxy.ts new file mode 100644 index 0000000..9953642 --- /dev/null +++ b/lib/api/fetchProxy.ts @@ -0,0 +1,10 @@ +import { proxy } from "./axiosInstanceApi"; + +// SSR에서 proxy로 요청 보낼 때 사용하는 로직 추상화 +const fetchProxy = async (endpoint: string, req: any) => { + const headers = req ? { Cookie: req.headers.cookie } : undefined; + const response = await proxy.get(endpoint, { headers }); + return response.data; +}; + +export default fetchProxy; diff --git a/pages/api/links/index.ts b/pages/api/links/index.ts index 6eb6bb1..bfc13d2 100644 --- a/pages/api/links/index.ts +++ b/pages/api/links/index.ts @@ -5,19 +5,16 @@ import axiosInstance from "@/lib/api/axiosInstanceApi"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const cookies = parse(req.headers.cookie || ""); const accessToken = cookies.accessToken; + const { page, pageSize, search } = req.query; switch (req.method) { case "GET": - const { page = "1", pageSize = "10" } = req.query; - // 유저의 전체 링크 조회 try { - const response = await axiosInstance.get( - `/links?page=${page}&pageSize=${pageSize}`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); + const response = await axiosInstance.get(`/links`, { + params: { page, pageSize, search }, // 만약 아무런 값이 없으면 알아서 예외시킴 + headers: { Authorization: `Bearer ${accessToken}` }, + }); return res.status(201).json(response.data); } catch (err) { console.error(err); diff --git a/pages/favorite/index.tsx b/pages/favorite/index.tsx index c54b44c..c1e9861 100644 --- a/pages/favorite/index.tsx +++ b/pages/favorite/index.tsx @@ -1,8 +1,8 @@ import { GetServerSideProps, GetServerSidePropsContext } from "next"; import { proxy } from "@/lib/api/axiosInstanceApi"; -import LinkCard from "@/components/LinkCard"; import CardsLayout from "@/components/Layout/CardsLayout"; import Container from "@/components/Layout/Container"; +import LinkCard from "@/components/Link/LinkCard"; interface FavoriteDataType { id: number; diff --git a/pages/fonts/GeistMonoVF.woff b/pages/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/pages/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/pages/fonts/GeistVF.woff b/pages/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/pages/fonts/GeistVF.woff and /dev/null differ diff --git a/pages/link/index.tsx b/pages/link/index.tsx index 97afa7e..65884e9 100644 --- a/pages/link/index.tsx +++ b/pages/link/index.tsx @@ -1,86 +1,75 @@ +import { useEffect, useState } from "react"; import { GetServerSidePropsContext } from "next"; -import { proxy } from "@/lib/api/axiosInstanceApi"; +import { useRouter } from "next/router"; import { LinkData } from "@/types/linkTypes"; import { FolderData } from "@/types/folderTypes"; -import { SearchInput } from "../../components/Search/SearchInput"; import { Modal } from "@/components/modal/modalManager/ModalManager"; import { useLinkCardStore } from "@/store/useLinkCardStore"; -import { useEffect } from "react"; -import Container from "@/components/Layout/Container"; -import CardsLayout from "@/components/Layout/CardsLayout"; -import ActionButtons from "@/components/Link/ActionButtons"; -import AddLinkInput from "@/components/Link/AddLinkInput"; -import FolderTag from "../../components/FolderTag"; -import LinkCard from "../../components/LinkCard"; +import { SearchInput } from "../../components/Search/SearchInput"; import useModalStore from "@/store/useModalStore"; import Pagination from "@/components/Pagination"; +import useFetchLinks from "@/hooks/useFetchLinks"; +import AddLinkInput from "@/components/Link/AddLinkInput"; +import Container from "@/components/Layout/Container"; +import SearchResultMessage from "@/components/Search/SearchResultMessage"; +import FolderTag from "@/components/Folder/FolderTag"; +import AddFolderButton from "@/components/Folder/AddFolderButton"; +import FolderActionsMenu from "@/components/Folder/FolderActionsMenu"; +import CardsLayout from "@/components/Layout/CardsLayout"; +import LinkCard from "@/components/Link/LinkCard"; +import fetchProxy from "@/lib/api/fetchProxy"; interface LinkPageProps { linkList: LinkData[]; folderList: FolderData[]; totalCount: number; - page: number; - pageSize: number; } +// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달. export const getServerSideProps = async ( context: GetServerSidePropsContext ) => { - const { req, query } = context; - - // 쿼리로부터 page와 pageSize를 읽고 기본값 설정 - const page = parseInt((query.page as string) || "1", 10); - const pageSize = parseInt((query.pageSize as string) || "6", 10); - - const fetchData = async (endpoint: string) => { - const response = await proxy.get(endpoint, { - headers: { - Cookie: req.headers.cookie, - }, - }); - return response.data; - }; + const { req } = context; const [links, folders] = await Promise.all([ - fetchData(`/api/links?page=${page}&pageSize=${pageSize}`), - fetchData("/api/folders"), + fetchProxy("/api/links", req), + fetchProxy("/api/folders", req), ]); return { props: { - link: links || [], linkList: links.list || [], folderList: folders || [], totalCount: links.totalCount || 0, - page, - pageSize, }, }; }; const LinkPage = ({ - linkList, - folderList, + linkList: initialLinkList, + folderList: initialFolderList, totalCount, - page, - pageSize, }: LinkPageProps) => { + const router = useRouter(); + const { search } = router.query; const { isOpen, openModal } = useModalStore(); const { linkCardList, setLinkCardList } = useLinkCardStore(); + const [folderList, setFolderList] = useState(initialFolderList); + + // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅 + useFetchLinks(router.query, setLinkCardList); // 클라이언트에서 초기 목록을 설정 useEffect(() => { - setLinkCardList(linkList); - }, [linkList, setLinkCardList]); - - // EditLink 호출 - const openEdit = (link: string, linkId: number) => { - openModal("EditLink", { link, linkId: linkId ?? null }); - }; + setLinkCardList(initialLinkList); + }, [initialLinkList, setLinkCardList]); - // DeleteLinkModal 호출 - const openDelete = (link: string, linkId: number) => { - openModal("DeleteLinkModal", { link, linkId: linkId ?? null }); + const handleModalOpen = ( + type: "EditLink" | "DeleteLinkModal", + link: string, + linkId: number + ) => { + openModal(type, { link, linkId }); }; return ( @@ -91,27 +80,28 @@ const LinkPage = ({
    + {search && }
    {folderList && } - +
    -
    +

    유용한 글

    - +
    {linkCardList.map((link) => ( openEdit(link.url, link.id)} - openDelete={() => openDelete(link.url, link.id)} + openEdit={() => handleModalOpen("EditLink", link.url, link.id)} + openDelete={() => + handleModalOpen("DeleteLinkModal", link.url, link.id) + } info={link} /> ))} - + {isOpen && }
    diff --git a/store/useLinkCardStore.tsx b/store/useLinkCardStore.tsx index 6f9de85..40b095b 100644 --- a/store/useLinkCardStore.tsx +++ b/store/useLinkCardStore.tsx @@ -1,23 +1,14 @@ import { deleteLinkURL, getLinks, putLinkURL } from "@/lib/api/link"; import { create } from "zustand"; - -interface LinkCardDataType { - id: number; - favorite: boolean; - url: string; - title: string; - imageSource: string; - description: string; - createdAt: string; -} +import { LinkData } from "@/types/linkTypes"; interface UpdateLinkBody { url: string; } interface LinkCardStore { - linkCardList: LinkCardDataType[]; - setLinkCardList: (list: LinkCardDataType[]) => void; + linkCardList: LinkData[]; + setLinkCardList: (list: LinkData[]) => void; updateLink: (linkId: number, body: UpdateLinkBody) => Promise; deleteLink: (linkId: number) => Promise; } @@ -25,7 +16,7 @@ interface LinkCardStore { export const useLinkCardStore = create((set) => ({ linkCardList: [], - setLinkCardList: (list: LinkCardDataType[]) => { + setLinkCardList: (list: LinkData[]) => { set({ linkCardList: list }); },