diff --git a/components/common/Card/Card.tsx b/components/common/Card/Card.tsx index 32de5558a..c8b7c41a5 100644 --- a/components/common/Card/Card.tsx +++ b/components/common/Card/Card.tsx @@ -5,24 +5,40 @@ import styles from "./card.module.css"; import DeleteModal from "@/components/common/Modal/DeleteModal/DeleteModal"; import FolderModal from "@/components/common/Modal/FolderModal/FolderModal"; +import { useDeleteLink } from "hooks/useDeleteLink"; + import { formatDate, getTimeDifference } from "utils/date"; import { MODALS } from "constants/modals"; import type { LinkItem, Folder } from "types"; import noImage from "@/images/bg_noImage.png"; -interface Props { +type Props = { item: LinkItem; + folderId: number | null; folderList: Folder[] | null; -} +}; + +export type Link = { + id: number; + favorite: boolean; + created_at: Date; + url: string; + title: string; + image_source: string; + description: string; + [key: string]: number | Date | string | boolean; +}; // TODO: Card 컴포넌트 분리 -function Card({ item, folderList }: Props) { - const { createdAt, created_at, description, imageSource, image_source, url } = - item; - - const date = createdAt || created_at; +function Card({ item, folderId, folderList }: Props) { + const { id, created_at: date, url, image_source: imgUrl, description } = item; - const imgUrl = imageSource || image_source; + const { + mutateAsync: deleteLink, + isPending, + isError, + error, + } = useDeleteLink(folderId); const absoluteImageUrl = imgUrl?.startsWith("//") ? `https:${imgUrl}` @@ -38,6 +54,11 @@ function Card({ item, folderList }: Props) { setCurrentModal(null); }; + const handleDelete = async () => { + await deleteLink(id); + closeModal(); + }; + const handleCardClick = (url: string) => { window.open(url, "_blank"); }; @@ -137,17 +158,18 @@ function Card({ item, folderList }: Props) { isOpen={currentModal === MODALS.deleteLink} title="폴더 삭제" deletion={url} + onClick={handleDelete} onCloseClick={closeModal} /> - + /> */} ); } diff --git a/components/common/CardList/CardList.tsx b/components/common/CardList/CardList.tsx index 419636eaf..fbb8b2823 100644 --- a/components/common/CardList/CardList.tsx +++ b/components/common/CardList/CardList.tsx @@ -4,12 +4,13 @@ import Card from "@/components/common/Card/Card"; import NoResults from "@/components/common/NoResults/NoResults"; import type { LinkItem, Folder } from "types"; -interface Props { +type Props = { + folderId: number | null; items: LinkItem[] | null; folderList: Folder[] | null; } -function CardList({ items, folderList }: Props) { +function CardList({ folderId, items, folderList }: Props) { if (!items || items.length === 0) { return ; } @@ -19,7 +20,7 @@ function CardList({ items, folderList }: Props) { diff --git a/components/common/LoginCheck/LoginCheck.tsx b/components/common/LoginCheck/LoginCheck.tsx new file mode 100644 index 000000000..0110718d6 --- /dev/null +++ b/components/common/LoginCheck/LoginCheck.tsx @@ -0,0 +1,24 @@ +import { useRouter } from "next/router"; +import { PropsWithChildren, useEffect } from "react"; + +import { ROUTE_PATHS } from "constants/route"; + +import useAuthStore from "store/authStore"; + +const LoginCheck = ({ children }: PropsWithChildren) => { + const accessToken = useAuthStore.getState().accessToken; + + const router = useRouter(); + + useEffect(() => { + if (accessToken) { + router.push(ROUTE_PATHS.folder); + } else { + return; + } + }, []); + + return
{children}
; +}; + +export default LoginCheck; diff --git a/components/common/Modal/DeleteModal/DeleteModal.tsx b/components/common/Modal/DeleteModal/DeleteModal.tsx index 278d1a925..a14d7da34 100644 --- a/components/common/Modal/DeleteModal/DeleteModal.tsx +++ b/components/common/Modal/DeleteModal/DeleteModal.tsx @@ -6,19 +6,22 @@ type Props = { isOpen: boolean; title: string; deletion: string; + onClick: MouseEventHandler; onCloseClick: MouseEventHandler; }; -const DeleteModal = ({ isOpen, title, deletion, onCloseClick }: Props) => { +const DeleteModal = ({ + isOpen, + title, + deletion, + onClick, + onCloseClick, +}: Props) => { return ( - +

{deletion}

-
diff --git a/components/common/Modal/FolderModal/FolderModal.tsx b/components/common/Modal/FolderModal/FolderModal.tsx index e3c49c866..550d6d0df 100644 --- a/components/common/Modal/FolderModal/FolderModal.tsx +++ b/components/common/Modal/FolderModal/FolderModal.tsx @@ -2,6 +2,8 @@ import { ChangeEventHandler, KeyboardEventHandler, MouseEventHandler, + useEffect, + useState, } from "react"; import styles from "./folder.module.css"; import BaseModal from "@/components/common/Modal/BaseModal/BaseModal"; @@ -16,6 +18,7 @@ type Props = { onCloseClick: MouseEventHandler; onKeyDown?: KeyboardEventHandler; onChange?: ChangeEventHandler; + onClick: (name: string) => void; }; const FolderModal = ({ @@ -25,25 +28,39 @@ const FolderModal = ({ link, folderList, onCloseClick, + onClick, }: Props) => { + const [value, setValue] = useState(""); + + const handleButtonClick = () => { + onClick(value); + setValue(""); + }; + return ( {folderList ? (

{link}

    - {folderList.map(({ id, name, link }) => ( + {folderList.map(({ id, name }) => (
  • {name}{" "} - {`${link.count}개 링크`} + {/* {`${link.count}개 링크`} */}
  • ))}
) : ( - + setValue(e.target.value)} + className={styles.input} + placeholder="내용 입력" + /> )} -
diff --git a/components/common/Modal/ShareModal/ShareModal.tsx b/components/common/Modal/ShareModal/ShareModal.tsx index 7b5259e28..5d2f2c936 100644 --- a/components/common/Modal/ShareModal/ShareModal.tsx +++ b/components/common/Modal/ShareModal/ShareModal.tsx @@ -11,7 +11,12 @@ type Props = { }; const ShareModal = ({ isOpen, title, folderName, onCloseClick }: Props) => { - const currentUrl = window.location.href; + + let currentUrl = '' + + if (typeof window !== "undefined") { + currentUrl = window.location.href; + } const copyToClipboard = () => { const textToCopy = currentUrl; diff --git a/components/common/Navbar/Navbar.tsx b/components/common/Navbar/Navbar.tsx index 8c6dc7cea..84637e5b9 100644 --- a/components/common/Navbar/Navbar.tsx +++ b/components/common/Navbar/Navbar.tsx @@ -1,27 +1,12 @@ -import { useEffect, useState } from "react"; import Link from "next/link"; import styles from "./navbar.module.css"; import Profile from "./Profile/Profile"; -import { fetchGetSampleUsers } from "hooks/useGetSampleData"; import { ROUTE_PATHS } from "constants/route"; -import type { User } from "types"; import Logo from "@/images/logo.svg"; +import { useGetUser } from "hooks/useGetUser"; -const ID = 1; - -function Navbar() { - const [user, setUser] = useState(); - - useEffect(() => { - fetchGetSampleUsers(ID) - .then((data: User[]) => { - const [userInfo] = data; - setUser(userInfo); - }) - .catch((err) => { - console.error(err); - }); - }, []); +const Navbar = () => { + const { data, isError, isPending } = useGetUser(); return ( ); -} +}; export default Navbar; diff --git a/components/folder/Category/Category.tsx b/components/folder/Category/Category.tsx index f91dfa44a..6583ef22f 100644 --- a/components/folder/Category/Category.tsx +++ b/components/folder/Category/Category.tsx @@ -1,29 +1,26 @@ import styles from "./category.module.css"; -import Link from "next/link"; import FolderButton from "@/components/folder/FolderButton/FolderButton"; -import { ROUTE_PATHS } from "constants/route"; -import { ALL } from "constants/etc"; + import type { Folder } from "types"; import type { SelectedCategory } from "pages/folder"; +const ALL = "전체"; -interface Props { +type Props = { buttonNames: Folder[]; selectedCategory: SelectedCategory; onClick: (id: number | null, name: string) => void; -} +}; -function Category({ buttonNames, selectedCategory, onClick }: Props) { +const Category = ({ buttonNames, selectedCategory, onClick }: Props) => { return (
- - onClick(null, ALL)} - > - {ALL} - - + onClick(null, ALL)} + > + {ALL} + {buttonNames.map(({ id, name }) => ( ); -} +}; export default Category; diff --git a/components/folder/FolderToolBar/FolderToolBar.tsx b/components/folder/FolderToolBar/FolderToolBar.tsx index 0d31c9e6d..15957ee25 100644 --- a/components/folder/FolderToolBar/FolderToolBar.tsx +++ b/components/folder/FolderToolBar/FolderToolBar.tsx @@ -7,7 +7,10 @@ import FolderModal from "@/components/common/Modal/FolderModal/FolderModal"; import DeleteModal from "@/components/common/Modal/DeleteModal/DeleteModal"; import ShareModal from "@/components/common/Modal/ShareModal/ShareModal"; -import { ALL } from "constants/etc"; +import { useDeleteFolder } from "hooks/useDeleteFolder"; +import { useAddFolder } from "hooks/useAddFolder"; +import { useUpdateFolder } from "hooks/useUpdateFolder"; + import { MODALS } from "constants/modals"; import AddIcon from "@/images/ic_add.svg"; @@ -27,10 +30,40 @@ const FolderToolBar = ({ }: Props) => { const [currentModal, setCurrentModal] = useState(null); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + + const { mutateAsync: addFolder } = useAddFolder(); + + const { mutateAsync: updateFolder, data, isPending } = useUpdateFolder(); + const closeModal = () => { setCurrentModal(null); }; + const handleFolderName = async (name: string) => { + if (selectedCategory.id) { + const [data] = await updateFolder({ + folderId: selectedCategory.id, + name, + }); + onCategoryClick(data.id, data.name); + closeModal(); + } + }; + + const handleAddFolder = async (name: string) => { + await addFolder(name); + closeModal(); + }; + + const handleDelete = async () => { + if (selectedCategory.id) { + await deleteFolder(selectedCategory.id); + onCategoryClick(null, "전체"); + closeModal(); + } + }; + return ( <>
@@ -54,14 +87,16 @@ const FolderToolBar = ({ isOpen={currentModal === MODALS.addFolder} title="폴더 추가" buttonText="추가하기" + onClick={handleAddFolder} onCloseClick={closeModal} />
{selectedCategory.name}
+
@@ -99,6 +135,7 @@ const FolderToolBar = ({ isOpen={currentModal === MODALS.deleteFolder} title="폴더 삭제" deletion={selectedCategory.name} + onClick={handleDelete} onCloseClick={closeModal} /> diff --git a/constants/auth.ts b/constants/auth.ts deleted file mode 100644 index 5b092f602..000000000 --- a/constants/auth.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const TOKEN = { - access: "access_token", -}; diff --git a/constants/endPoint.ts b/constants/endPoint.ts new file mode 100644 index 000000000..b0c49b8a6 --- /dev/null +++ b/constants/endPoint.ts @@ -0,0 +1,14 @@ +export const API_ENDPOINTS = { + FOLDERS: "/folders", + FOLDER_DETAIL: (folderId: number) => `/folders/${folderId}`, + + LINKS: "/links", + FOLDER_LINKS_DETAIL: (folderId: number) => `/folders/${folderId}/links`, + LINK_DETAIL: (linkId: number) => `/links/${linkId}`, + + USERS: "/users", + CHECK_EMAIL: "/users/check-email", + + SIGN_IN: "/auth/sign-in", + SIGN_UP: "/auth/sign-up", +} as const; diff --git a/constants/etc.ts b/constants/etc.ts deleted file mode 100644 index c0d04dfaf..000000000 --- a/constants/etc.ts +++ /dev/null @@ -1 +0,0 @@ -export const ALL = "전체"; \ No newline at end of file diff --git a/constants/queryKey.ts b/constants/queryKey.ts new file mode 100644 index 000000000..b3c325408 --- /dev/null +++ b/constants/queryKey.ts @@ -0,0 +1,6 @@ +export const QUERY_KEYS = { + USER: ["user"] as const, + FOLDERS: ["folders"] as const, + LINKS: (folderId?: number | null) => + folderId ? (["links", folderId] as const) : (["links"] as const), +}; diff --git a/constants/route.ts b/constants/route.ts index eaf127fd3..dced482a4 100644 --- a/constants/route.ts +++ b/constants/route.ts @@ -1,8 +1,9 @@ export const ROUTE_PATHS = { home: "/", + user: "/users", mypage: "/mypage", login: "/signin", signup: "/signup", folder: "/folder", - shared: "/shoard", -}; + shared: "/shared", +} as const; diff --git a/hooks/useAddFolder.ts b/hooks/useAddFolder.ts new file mode 100644 index 000000000..1b36d1802 --- /dev/null +++ b/hooks/useAddFolder.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; + +export const useAddFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => { + return instance.post(API_ENDPOINTS.FOLDERS, { name }); + }, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.FOLDERS, + }), + }); +}; diff --git a/hooks/useDeleteFolder.ts b/hooks/useDeleteFolder.ts new file mode 100644 index 000000000..3de73a619 --- /dev/null +++ b/hooks/useDeleteFolder.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; + +export const useDeleteFolder = () => { + const deleteFolder = async (folderId: number) => { + try { + await instance.delete(API_ENDPOINTS.FOLDER_DETAIL(folderId)); + } catch (error) { + throw error; + } + }; + + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteFolder, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.FOLDERS, + }), + }); +}; diff --git a/hooks/useDeleteLink.ts b/hooks/useDeleteLink.ts new file mode 100644 index 000000000..b7ae4c56c --- /dev/null +++ b/hooks/useDeleteLink.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; + +export const useDeleteLink = (folderId: number | null) => { + const deleteLink = async (linkId: number) => { + try { + await instance.delete(API_ENDPOINTS.LINK_DETAIL(linkId)); + } catch (error) { + throw error; + } + }; + + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteLink, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LINKS(folderId), + }), + }); +}; diff --git a/hooks/useFetch.ts b/hooks/useFetch.ts deleted file mode 100644 index 0879833eb..000000000 --- a/hooks/useFetch.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useState } from "react"; -import instance from "lib/axios"; - -const useFetch = (url: string) => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - setLoading(true); - const response = await instance.get(url); - setData(response?.data); - } catch (error) { - if (error instanceof Error) { - setError(error); - } - } finally { - setLoading(false); - } - }; - fetchData(); - }, [url]); - - return { data, loading, error }; -}; - -export default useFetch; diff --git a/hooks/useGetFolders.ts b/hooks/useGetFolders.ts index e3096d19e..c00960cf4 100644 --- a/hooks/useGetFolders.ts +++ b/hooks/useGetFolders.ts @@ -1,12 +1,26 @@ -import useFetch from "hooks/useFetch"; -import type { Folder } from "types"; +import { useQuery } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; -export const useGetFolders = (userId: number) => { - const { data, loading, error } = useFetch<{ data: Folder[] }>( - `/users/${userId}/folders` - ); +import type { Folder } from "types"; - const folderData = data?.data ?? []; +const fetchFolders = async () => { + try { + const { data } = await instance.get>( + API_ENDPOINTS.FOLDERS + ); + return data; + } catch (error) { + throw error; + } +}; - return { data: folderData, loading, error }; +export const useGetFolders = () => { + return useQuery({ + queryKey: QUERY_KEYS.FOLDERS, + queryFn: fetchFolders, + staleTime: 1000 * 60, + }); }; diff --git a/hooks/useGetLinks.ts b/hooks/useGetLinks.ts index 56dc2328a..b178d0436 100644 --- a/hooks/useGetLinks.ts +++ b/hooks/useGetLinks.ts @@ -1,14 +1,35 @@ -import useFetch from "hooks/useFetch"; -import type { LinkItem } from "types"; +import { useQuery } from "@tanstack/react-query"; +import instance from "lib/axios"; -export const useGetLinks = (userId: number, folderId: number | null) => { - const query = folderId ? `?folderId=${folderId}` : ""; +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; - const { data, loading, error } = useFetch<{ data: LinkItem[] }>( - `users/${userId}/links${query}` - ); +export type Link = { + id: number; + favorite: boolean; + created_at: Date; + url: string; + title: string; + image_source: string; + description: string; +}; - const linkData = data?.data ?? []; +export const useGetLinks = (folderId: number | null) => { + const fetchLinks = async () => { + try { + const url = folderId + ? API_ENDPOINTS.FOLDER_LINKS_DETAIL(folderId) + : API_ENDPOINTS.LINKS; + const { data } = await instance.get(url); + return data; + } catch (error) { + throw error; + } + }; - return { data: linkData, loading, error }; + return useQuery({ + queryKey: QUERY_KEYS.LINKS(folderId), + queryFn: fetchLinks, + staleTime: 1000 * 60, + }); }; diff --git a/hooks/useGetSampleData.ts b/hooks/useGetSampleData.ts deleted file mode 100644 index 30519fa4a..000000000 --- a/hooks/useGetSampleData.ts +++ /dev/null @@ -1,27 +0,0 @@ -const API = process.env.NEXT_PUBLIC_API; - -export const fetchGetSampleFolders = async () => { - try { - const response = await fetch(`${API}/sample/folder`); - if (!response.ok) { - throw new Error(`Server responded with status: ${response.status}`); - } - const data = await response.json(); - return data; - } catch (error) { - console.error(error); - } -}; - -export const fetchGetSampleUsers = async (userId: number) => { - try { - const response = await fetch(`${API}/users/${userId}`); - if (!response.ok) { - throw new Error(`Server responded with status: ${response.status}`); - } - const result = await response.json(); - return result.data; - } catch (error) { - console.error(error); - } -}; diff --git a/hooks/useGetUser.ts b/hooks/useGetUser.ts new file mode 100644 index 000000000..ac7f0c8d2 --- /dev/null +++ b/hooks/useGetUser.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; + +type UserResponse = { + id: number; + name: string; + image_source: string; + email: string; +}; + +const fetchUser = async () => { + const { + data: [user], + } = await instance.get(API_ENDPOINTS.USERS); + return user; +}; + +export const useGetUser = () => { + return useQuery({ + queryKey: QUERY_KEYS.USER, + queryFn: fetchUser, + staleTime: 1000 * 60 * 60, + }); +}; diff --git a/hooks/useLocalStorage.ts b/hooks/useLocalStorage.ts deleted file mode 100644 index 0c8b48b6a..000000000 --- a/hooks/useLocalStorage.ts +++ /dev/null @@ -1,17 +0,0 @@ -//import { useEffect, useState } from "react"; - -// const useLocalStorage = (key: string, defaultState: string | null) => { -// const [value, setValue] = useState(localStorage.getItem(key) || defaultState); - -// useEffect(() => { -// localStorage.setItem(key, JSON.stringify(value)); -// }, [value]); - -// return [value, setValue]; -// }; - -const useLocalStorage = (key: string, defaultState: string) => { - localStorage.setItem(key, JSON.stringify(defaultState)); -}; - -export default useLocalStorage; diff --git a/hooks/useLogin.ts b/hooks/useLogin.ts new file mode 100644 index 000000000..6f626f5e4 --- /dev/null +++ b/hooks/useLogin.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import useAuthStore from "store/authStore"; +import Cookies from "js-cookie"; + +import { API_ENDPOINTS } from "constants/endPoint"; + +import type { User } from "types"; + +const useLogin = () => { + const setAccessToken = useAuthStore((state) => state.setAccessToken); + + const mutation = useMutation({ + mutationFn: (data: User) => { + return instance.post(API_ENDPOINTS.SIGN_IN, data); + }, + onSuccess: (res) => { + const { accessToken, refreshToken } = res.data; + setAccessToken(accessToken); + Cookies.set("refreshToken", refreshToken, { expires: 7 }); + }, + onError: (error) => { + throw error; + }, + }); + + const login = async (data: User) => { + mutation.mutate(data); + }; + + return { + login, + isPending: mutation.isPending, + isSuccess: mutation.isSuccess, + }; +}; + +export default useLogin; diff --git a/hooks/useSignUp.ts b/hooks/useSignUp.ts new file mode 100644 index 000000000..bc930c16f --- /dev/null +++ b/hooks/useSignUp.ts @@ -0,0 +1,41 @@ +import { useMutation } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { API_ENDPOINTS } from "constants/endPoint"; +import { User } from "types"; + +const useSignUp = () => { + const checkEmailMutation = useMutation({ + mutationFn: (email: string) => { + return instance.post(API_ENDPOINTS.CHECK_EMAIL, { email }); + }, + onError: (error) => { + throw error; + }, + }); + + const signUpMutation = useMutation({ + mutationFn: (data: User) => { + return instance.post(API_ENDPOINTS.SIGN_UP, data); + }, + onError: (error) => { + throw error; + }, + }); + + const signUp = async (email: string, password: string) => { + try { + await checkEmailMutation.mutateAsync(email); + await signUpMutation.mutateAsync({ email, password }); + } catch (error) { + throw error; + } + }; + + return { + signUp, + isPending: checkEmailMutation.isPending || signUpMutation.isPending, + }; +}; + +export default useSignUp; diff --git a/hooks/useUpdateFolder.ts b/hooks/useUpdateFolder.ts new file mode 100644 index 000000000..4f5a71193 --- /dev/null +++ b/hooks/useUpdateFolder.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import instance from "lib/axios"; + +import { QUERY_KEYS } from "constants/queryKey"; +import { API_ENDPOINTS } from "constants/endPoint"; + +import type { Folder } from "types"; + +export const useUpdateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { folderId: number; name: string }) => { + const response = await instance.put>( + API_ENDPOINTS.FOLDER_DETAIL(data.folderId), + { + name: data.name, + } + ); + return response.data; + }, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.FOLDERS, + }), + onError: (error) => { + throw error; + }, + }); +}; diff --git a/lib/axios.ts b/lib/axios.ts index 8200de696..8b15696b3 100644 --- a/lib/axios.ts +++ b/lib/axios.ts @@ -1,7 +1,37 @@ import axios from "axios"; +import type { + InternalAxiosRequestConfig, + AxiosRequestConfig, + AxiosError, +} from "axios"; +import useAuthStore from "store/authStore"; const instance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, + headers: { "Content-Type": "application/json" }, }); +const onRequest = (config: InternalAxiosRequestConfig) => { + const accessToken = useAuthStore.getState().accessToken; + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +const onError = (error: AxiosError) => { + // Unauthorized 응답 + if (error.isAxiosError) { + if (error.response?.status === 404) { + console.log("존재하지 않는 유저"); + } else { + console.log("인증 오류"); + } + } +}; + +instance.interceptors.request.use(onRequest, onError); + export default instance; diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index 5353c4bed..000000000 --- a/middleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from "next/server"; -import { NextRequest } from "next/server"; -import { ROUTE_PATHS } from "constants/route"; -import { TOKEN } from "constants/auth"; - -export function middleware(request: NextRequest) { - if (typeof window !== "undefined") { - const accessToken = localStorage.getItem(TOKEN.access); - if (accessToken) { - return NextResponse.redirect(new URL(ROUTE_PATHS.folder, request.url)); - } - } - return NextResponse.next(); -} - -export const config = { - matcher: ["/signin/:path", "/signup/:path"], -}; diff --git a/package-lock.json b/package-lock.json index 971dd17cd..96a722e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.3.4", + "@tanstack/react-query": "^5.35.5", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -17,16 +18,20 @@ "@types/react-dom": "^18.2.22", "axios": "^1.6.8", "clsx": "^2.1.0", + "js-cookie": "^3.0.5", "next": "^14.1.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.2", "typescript": "^5.4.2", "web-vitals": "^2.1.4", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.2" }, "devDependencies": { - "@svgr/webpack": "^8.1.0" + "@svgr/webpack": "^8.1.0", + "@tanstack/react-query-devtools": "^5.35.5", + "@types/js-cookie": "^3.0.6" } }, "node_modules/@adobe/css-tools": { @@ -2424,6 +2429,57 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.35.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.35.5.tgz", + "integrity": "sha512-OMWvlEqG01RfGj+XZb/piDzPp0eZkkHWSDHt2LvE/fd1zWburP/xwm0ghk6Iv8cuPlP+ACFkZviKXK0OVt6lhg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.32.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.32.1.tgz", + "integrity": "sha512-7Xq57Ctopiy/4atpb0uNY5VRuCqRS/1fi/WBCKKX6jHMa6cCgDuV/AQuiwRXcKARbq2OkVAOrW2v4xK9nTbcCA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.35.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.35.5.tgz", + "integrity": "sha512-sppX7L+PVn5GBV3In6zzj0zcKfnZRKhXbX1MfIfKo1OjIq2GMaopvAFOP0x1bRYTUk2ikrdYcQYOozX7PWkb8A==", + "dependencies": { + "@tanstack/query-core": "5.35.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.35.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.35.5.tgz", + "integrity": "sha512-4Xll14B9uhgEJ+uqZZ5tqZ7G1LDR7wGYgb+NOZHGn11TTABnlV8GWon7zDMqdaHeR5mjjuY1UFo9pbz39kuZKQ==", + "dev": true, + "dependencies": { + "@tanstack/query-devtools": "5.32.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.35.5", + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -2599,6 +2655,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", @@ -3973,6 +4035,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4873,6 +4943,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", @@ -4938,6 +5016,33 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", + "integrity": "sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index d102ee975..ca7b1e60c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@hookform/resolvers": "^3.3.4", + "@tanstack/react-query": "^5.35.5", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -12,13 +13,15 @@ "@types/react-dom": "^18.2.22", "axios": "^1.6.8", "clsx": "^2.1.0", + "js-cookie": "^3.0.5", "next": "^14.1.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.2", "typescript": "^5.4.2", "web-vitals": "^2.1.4", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.2" }, "scripts": { "dev": "next dev", @@ -44,6 +47,8 @@ ] }, "devDependencies": { - "@svgr/webpack": "^8.1.0" + "@svgr/webpack": "^8.1.0", + "@tanstack/react-query-devtools": "^5.35.5", + "@types/js-cookie": "^3.0.6" } } diff --git a/pages/Home.module.css b/pages/Home.module.css new file mode 100644 index 000000000..516154cc0 --- /dev/null +++ b/pages/Home.module.css @@ -0,0 +1,60 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.headerWrapper { + width: 100%; + background-color: #f0f6ff; +} + +.header { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + margin-top: 70px; +} + +.headerText { + font-size: 64px; + font-weight: 700; +} + +.block { + display: block; + margin: auto; +} + +.headerText .gradient { + background: linear-gradient(91deg, #6d6afe 17.28%, #ff9f9f 74.98%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.linkAddBtn { + width: 350px; + padding: 16px 20px; + border-radius: 8px; + background: var( + --gra-purpleblue-to-skyblue, + linear-gradient(91deg, #6d6afe 0.12%, #6ae3fe 101.84%) + ); + color: #ffffff; + font-size: 18px; +} + +.section { + display: flex; + align-items: center; + gap: 157px; + margin: 100px 0; +} + +.h2{ + font-size: 48px; +} \ No newline at end of file diff --git a/pages/_app.tsx b/pages/_app.tsx index 8f8a0c0c7..17ff00fcc 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,18 +1,31 @@ -import type { ReactElement, ReactNode } from 'react' -import type { NextPage } from 'next' +import type { ReactElement, ReactNode } from "react"; +import type { NextPage } from "next"; import type { AppProps } from "next/app"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import "styles/globals.css"; +const queryClient = new QueryClient(); + export type NextPageWithLayout

= NextPage & { - getLayout?: (page: ReactElement) => ReactNode -} - + getLayout?: (page: ReactElement) => ReactNode; +}; + type AppPropsWithLayout = AppProps & { - Component: NextPageWithLayout -} + Component: NextPageWithLayout; +}; export default function App({ Component, pageProps }: AppPropsWithLayout) { - const getLayout = Component.getLayout ?? ((page) => page) - - return getLayout() + const getLayout = Component.getLayout ?? ((page) => page); + + return ( + + {getLayout( + <> + + + + )} + + ); } diff --git a/pages/folder/index.tsx b/pages/folder/index.tsx index bad1ee215..60730aa56 100644 --- a/pages/folder/index.tsx +++ b/pages/folder/index.tsx @@ -1,4 +1,5 @@ import React, { ChangeEvent, useState, ReactElement, useRef } from "react"; +import { useRouter } from "next/router"; import styles from "./folder.module.css"; import SearchBar from "@/components/common/SearchBar/SearchBar"; @@ -12,12 +13,20 @@ import { useGetLinks } from "hooks/useGetLinks"; import { useGetFolders } from "hooks/useGetFolders"; import useIntersectionObserver from "hooks/useIntersectionObserver"; -import type { LinkItem } from "types"; import type { NextPageWithLayout } from "../_app"; -import { ALL } from "constants/etc"; - -const USERID = 11; +const ALL = "전체"; + +export type Link = { + id: number; + favorite: boolean; + created_at: Date; + url: string; + title: string; + image_source: string; + description: string; + [key: string]: number | Date | string | boolean; +}; export type SelectedCategory = { id: number | null; @@ -25,14 +34,16 @@ export type SelectedCategory = { }; const FolderPage: NextPageWithLayout = () => { + const router = useRouter(); + const [selectedCategory, setSelectedCategory] = useState({ id: null, name: ALL, }); - const { data: folders } = useGetFolders(USERID); + const { data: folders, isPending, isError } = useGetFolders(); - const { data: folderLinks } = useGetLinks(USERID, selectedCategory.id); + const { data: folderLinks } = useGetLinks(selectedCategory.id); const [searchText, setSearchText] = useState(""); @@ -42,7 +53,7 @@ const FolderPage: NextPageWithLayout = () => { setSearchText(e.target.value); }; - const filterSearchText = (items: LinkItem[]) => { + const filterSearchText = (items: Link[]) => { return items.filter((item) => { return searchParam.some((newItem) => { return ( @@ -58,6 +69,7 @@ const FolderPage: NextPageWithLayout = () => { const handleCategoryClick = (id: number | null, name: string) => { setSelectedCategory({ id, name }); + router.push(`/folder?folderId=${id}`, undefined, { shallow: true }); }; const handleDeletedClick = () => { @@ -71,6 +83,14 @@ const FolderPage: NextPageWithLayout = () => { }); const isVisibleFooter = useIntersectionObserver(fooerRef, { threshold: 1 }); + if (isPending) { + return

로딩 중 입니다.
; + } + + if (isError) { + return
에러가 발생했습니다. 다시 시도해주세요.
; + } + return (
@@ -99,6 +119,7 @@ const FolderPage: NextPageWithLayout = () => { {folderLinks && ( diff --git a/pages/index.tsx b/pages/index.tsx index d7175aaf7..d4eb13bc8 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,24 +1,46 @@ import { ReactElement } from "react"; -import Link from "next/link"; import Layout from "@/components/common/Layout/Layout"; -import { ROUTE_PATHS } from "constants/route"; import type { NextPageWithLayout } from "./_app"; +import styles from "./Home.module.css"; +import Image from "next/image"; +import { ROUTE_PATHS } from "constants/route"; +import Link from "next/link"; const HomePage: NextPageWithLayout = () => { return ( -
- Home 페이지 -
    -
  • - 폴더 페이지 이동 -
  • -
  • - 공유 페이지 이동 -
  • -
  • - 로그인 페이지 이동 -
  • -
+
+
+
+

+ + 세상의 모든 정보를 + + 쉽게 저장하고 관리해 보세요 +

+ + + + +
+
+ +
+
+

+ 원하는 링크를저장하세요 +

+

+ + 나중에 읽고 싶은 글, 다시 보고 싶은 영상, + + 사고 싶은 옷, 기억하고 싶은 + + 모든 것을 한 공간에 저장하세요. + +

+
+ +
); }; diff --git a/pages/signin/index.tsx b/pages/signin/index.tsx index ad1c018e8..882bc48d1 100644 --- a/pages/signin/index.tsx +++ b/pages/signin/index.tsx @@ -12,6 +12,8 @@ import styles from "./signin.module.css"; import Logo from "@/images/logo.svg"; import { ROUTE_PATHS } from "constants/route"; import { TOKEN } from "constants/auth"; +import LoginCheck from "@/components/common/LoginCheck/LoginCheck"; +import useLogin from "hooks/useLogin"; const SignIn = () => { const { @@ -26,26 +28,14 @@ const SignIn = () => { const router = useRouter(); - const postData = async (email: string, password: string) => { + const { login, isPending } = useLogin(); + + const onSubmit: SubmitHandler = async (data) => { try { - const response = await instance.post("/sign-in", { email, password }); - const result = response.data; - return result; + await login(data); + router.push(ROUTE_PATHS.home); } catch (error) { if (axios.isAxiosError(error)) { - throw error; - } - } - }; - - const onSubmit: SubmitHandler = async (data) => { - const { email, password } = data; - postData(email, password) - .then((res) => { - useLocalStorage(TOKEN.access, res.data.accessToken); - router.push(ROUTE_PATHS.folder); - }) - .catch(() => { setError("email", { type: "400", message: "이메일을 확인해 주세요.", @@ -54,44 +44,51 @@ const SignIn = () => { type: "400", message: "비밀번호를 확인해 주세요.", }); - }); + } + } }; return ( -
- + +
+ - - -
- - + + + + - - - 소셜 로그인 -
+ + + 소셜 로그인 +
+ ); }; diff --git a/pages/signup/index.tsx b/pages/signup/index.tsx index 778077866..550d72cff 100644 --- a/pages/signup/index.tsx +++ b/pages/signup/index.tsx @@ -1,5 +1,4 @@ import { useRouter } from "next/router"; -import instance from "lib/axios"; import axios from "axios"; import { useForm, SubmitHandler } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -7,6 +6,7 @@ import styles from "./signup.module.css"; import { Register, registerSchema } from "lib/zod/schema/RegisterSchema"; import InputField from "@/components/common/InputField/InputField"; import { ROUTE_PATHS } from "constants/route"; +import useSignUp from "hooks/useSignUp"; const SignUp = () => { const { @@ -21,31 +21,30 @@ const SignUp = () => { const router = useRouter(); - const postData = async (email: string, password: string) => { + const { signUp } = useSignUp(); + + const onSubmit: SubmitHandler = async (data) => { + const { email, password } = data; + try { - const response = await instance.post("/sign-up", { email, password }); - const result = response.data; + await signUp(email, password); + router.push(ROUTE_PATHS.login); } catch (error) { if (axios.isAxiosError(error)) { - throw error; + if (error.response?.status === 409) { + setError("email", { + type: "409", + message: "이미 사용 중인 이메일입니다.", + }); + } else { + setError("email", { + message: "다시 시도해 주세요.", + }); + } } } }; - const onSubmit: SubmitHandler = async (data) => { - const { email, password } = data; - postData(email, password) - .then(() => { - router.push(ROUTE_PATHS.folder); - }) - .catch(() => { - setError("email", { - type: "400", - message: "이미 사용 중인 이메일입니다.", - }); - }); - }; - return (
diff --git a/public/images/bg.svg b/public/images/bg.svg new file mode 100644 index 000000000..1d47f5b9f --- /dev/null +++ b/public/images/bg.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/bg_home.svg b/public/images/bg_home.svg new file mode 100644 index 000000000..4bae4266b --- /dev/null +++ b/public/images/bg_home.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/store/authStore.ts b/store/authStore.ts new file mode 100644 index 000000000..7bbc25a18 --- /dev/null +++ b/store/authStore.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface AuthState { + accessToken: string | null; + setAccessToken: (token: string | null) => void; +} + +const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + setAccessToken: (token) => set({ accessToken: token }), + }), + { + name: "auth-storage", + getStorage: () => localStorage, + } + ) +); + +export default useAuthStore; diff --git a/types/index.ts b/types/index.ts index 52eae5de3..9ed2394a0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,21 +1,15 @@ export type Folder = { id: number; created_at: Date; - name: string; - user_id: number; favorite: boolean; - link: { - count: number; - }; + name: string; + link_count?: number; + user_id?: number; }; export type User = { - id: number; - created_at: Date; - name: string; - image_source: string; email: string; - auth_id: string; + password: string; }; export type LinkItem = {