diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index e4bda03c..79ad28a5 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -28,6 +28,7 @@ jobs: echo "REACT_APP_E_BOOK_LIBRARY=$REACT_APP_E_BOOK_LIBRARY" >> .env echo "REACT_APP_SUGGESTION=$REACT_APP_SUGGESTION" >> .env echo "REACT_APP_GA_ID=$REACT_APP_GA_ID" >> .env + echo "REACT_APP_SENTRY=$REACT_APP_SENTRY" >> .env echo "PORT=$PORT" >> .env env: REACT_APP_API: ${{ secrets.REACT_APP_API }} @@ -53,12 +54,4 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | - aws s3 sync ./build s3://42library.kr --region ap-northeast-2 - - - name: invalidate CDN cache - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_CDN_DISTRIBUTION_ID: ${{ secrets.AWS_CDN_DISTRIBUTION_ID }} - run: | - aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CDN_DISTRIBUTION_ID }} --paths "/*" --region ap-northeast-2 + aws s3 sync ./build s3://42library.kr --region ap-northeast-2 --delete diff --git a/package.json b/package.json index 62c8352c..97d155dc 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "dependencies": { "@ladle/react": "^2.13.0", + "@react-spring/web": "^9.7.3", "@sentry/react": "^7.53.0", - "@sentry/tracing": "^7.53.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", @@ -51,6 +51,7 @@ ] }, "devDependencies": { + "@sentry/types": "^7.93.0", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", "@types/react-router-dom": "^5.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c55ed7e..ce5e2f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,12 @@ dependencies: '@ladle/react': specifier: ^2.13.0 version: 2.13.0(react-dom@18.2.0)(react@18.2.0) + '@react-spring/web': + specifier: ^9.7.3 + version: 9.7.3(react-dom@18.2.0)(react@18.2.0) '@sentry/react': specifier: ^7.53.0 version: 7.53.0(react@18.2.0) - '@sentry/tracing': - specifier: ^7.53.0 - version: 7.53.0 '@testing-library/jest-dom': specifier: ^5.16.5 version: 5.16.5 @@ -73,6 +73,9 @@ dependencies: version: 3.3.1 devDependencies: + '@sentry/types': + specifier: ^7.93.0 + version: 7.93.0 '@types/react': specifier: ^18.2.6 version: 18.2.6 @@ -765,6 +768,54 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 + /@react-spring/animated@9.7.3(react@18.2.0): + resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/core@9.7.3(react@18.2.0): + resolution: {integrity: sha512-IqFdPVf3ZOC1Cx7+M0cXf4odNLxDC+n7IN3MDcVCTIOSBfqEcBebSv+vlY5AhM0zw05PDbjKrNmBpzv/AqpjnQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/animated': 9.7.3(react@18.2.0) + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/shared@9.7.3(react@18.2.0): + resolution: {integrity: sha512-NEopD+9S5xYyQ0pGtioacLhL2luflh6HACSSDUZOwLHoxA5eku1UPuqcJqjwSD6luKjjLfiLOspxo43FUHKKSA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/types@9.7.3: + resolution: {integrity: sha512-Kpx/fQ/ZFX31OtlqVEFfgaD1ACzul4NksrvIgYfIFq9JpDHFwQkMVZ10tbo0FU/grje4rcL4EIrjekl3kYwgWw==} + dev: false + + /@react-spring/web@9.7.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BXt6BpS9aJL/QdVqEIX9YoUy8CE6TJrU0mNCqSoxdXlIeNcEBWOfIyE6B14ENNsyQKS3wOWkiJfco0tCr/9tUg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/animated': 9.7.3(react@18.2.0) + '@react-spring/core': 9.7.3(react@18.2.0) + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@remix-run/router@1.6.2: resolution: {integrity: sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==} engines: {node: '>=14'} @@ -824,18 +875,16 @@ packages: '@sentry/utils': 7.53.0 dev: false - /@sentry/tracing@7.53.0: - resolution: {integrity: sha512-s0vwXOyn3BEFzuHRGFfZJycmLMnKi74wDoForvbWxhoxgUJNAocuShVSMx4pClCaNtfkOGgD/axG2wxSpLHHxQ==} - engines: {node: '>=8'} - dependencies: - '@sentry-internal/tracing': 7.53.0 - dev: false - /@sentry/types@7.53.0: resolution: {integrity: sha512-hqlbrCL8nfDfjlF6wh4NHNW9plhWJ1m2BSqRyspcxOC44e293BPfwjUzr0aQapJK/aRkHROnfAtzsEu2awNPzg==} engines: {node: '>=8'} dev: false + /@sentry/types@7.93.0: + resolution: {integrity: sha512-UnzUccNakhFRA/esWBWP+0v7cjNg+RilFBQC03Mv9OEMaZaS29zSbcOGtRzuFOXXLBdbr44BWADqpz3VW0XaNw==} + engines: {node: '>=8'} + dev: true + /@sentry/utils@7.53.0: resolution: {integrity: sha512-T3F2DkXB9fDjUIAcrOMrGCnquyTQxTyTb5Mh+NbFBevQn32fHehQ62VraLhe20HTVy8YBFkXisvLu6HeOZ5Mog==} engines: {node: '>=8'} diff --git a/src/App.jsx b/src/App.tsx similarity index 86% rename from src/App.jsx rename to src/App.tsx index a7371532..995ea886 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import { useEffect } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { useSetRecoilState } from "recoil"; +import { BrowserRouter, Route } from "react-router-dom"; +import { useRecoilValue, useResetRecoilState } from "recoil"; import { install } from "ga-gtag"; +import { isUserExpiredAtom, userAtom } from "./atom/userAtom"; +import { SentryRoutes } from "./config/sentry"; + import BookDetail from "./component/book/BookDetail"; import Footer from "./component/utils/Footer"; import NotFound from "./component/utils/NotFound"; @@ -18,31 +21,28 @@ import History from "./component/history/History"; import ReservedLoan from "./component/reservedloan/ReservedLoan"; import ReturnBook from "./component/return/ReturnBook"; import UserManagement from "./component/userManagement/UserManagement"; -import AddBook from "./component/addbook/AddBook"; -import MyPageRoutes from "./component/mypage/MyPageRoutes"; -import userState from "./atom/userState"; -import Mypage from "./component/mypage/Mypage"; -import EditEmailOrPassword from "./component/mypage/EditEmailOrPassword"; -import LimitedRoute from "./LimitedRoute"; -import { isExpiredDate } from "./util/date"; -import BookManagement from "./component/bookManagement/BookManagement"; -import ReviewManagement from "./component/reviewManagement/ReviewManagement"; import BookStock from "./component/bookStock/BookStock"; import ELibraryIn42Box from "./component/eLibraryIn42Box/EventPage"; import SuperTagManagement from "./component/superTag/SuperTagManagement"; import SubTagManagement from "./component/subTag/SubTagManagement"; import Portals from "./component/utils/Portals"; +import LimitedRoute from "./LimitedRoute"; +import AddBook from "./component/addbook/AddBook"; +import BookManagement from "./component/bookManagement/BookManagement"; +import EditEmailOrPassword from "./component/mypage/EditEmailOrPassword"; +import MyPageRoutes from "./component/mypage/MyPageRoutes"; +import Mypage from "./component/mypage/Mypage"; +import ReviewManagement from "./component/reviewManagement/ReviewManagement"; import "./asset/css/reset.css"; function App() { - const setUser = useSetRecoilState(userState); - useEffect(() => { - install(import.meta.env.REACT_APP_GA_ID); - const localUser = JSON.parse(window.localStorage.getItem("user")); + const isUserExpired = useRecoilValue(isUserExpiredAtom); + const resetUser = useResetRecoilState(userAtom); - if (localUser?.isLogin) { - if (!isExpiredDate(localUser?.expire)) setUser(localUser); - else window.localStorage.removeItem("user"); + useEffect(() => install(import.meta.env.REACT_APP_GA_ID), []); + useEffect(() => { + if (isUserExpired) { + resetUser(); } }, []); @@ -51,7 +51,7 @@ function App() {
- + } /> } /> } /> @@ -83,7 +83,7 @@ function App() { } /> - +
diff --git a/src/component/book/review/PostReview.tsx b/src/component/book/review/PostReview.tsx index 64822d7a..fa832771 100644 --- a/src/component/book/review/PostReview.tsx +++ b/src/component/book/review/PostReview.tsx @@ -1,10 +1,11 @@ import { FormEventHandler } from "react"; -import "../../../asset/css/Review.css"; -import Button from "../../utils/Button"; -import { useNewDialog } from "../../../hook/useNewDialog"; import { useRecoilValue } from "recoil"; -import userState from "../../../atom/userState"; -import { usePostReview } from "../../../api/reviews/usePostReview"; + +import "~/asset/css/Review.css"; +import { useNewDialog } from "~/hook/useNewDialog"; +import { userAtom } from "~/atom/userAtom"; +import { usePostReview } from "~/api/reviews/usePostReview"; +import Button from "~/component/utils/Button" type Props = { bookInfoId: number; @@ -17,7 +18,7 @@ const PostReview = ({ bookInfoId, resetTab }: Props) => { resetTab, }); - const user = useRecoilValue(userState); + const user = useRecoilValue(userAtom); const hasPermissionToPostReview = user && user.userName !== user.email; // 인증된 유저는 이메일과 다른 닉네임을 가짐 const isValidLength = content.length >= 10 && content.length <= 420; const { addDialogWithTitleAndMessage, addConfirmDialog } = useNewDialog(); diff --git a/src/component/book/tag/Tag.tsx b/src/component/book/tag/Tag.tsx index 9f5d6459..40dab343 100644 --- a/src/component/book/tag/Tag.tsx +++ b/src/component/book/tag/Tag.tsx @@ -1,16 +1,15 @@ -import { MouseEventHandler } from "react"; -import { useState } from "react"; -import { TagType } from "../../../type/TagType"; +import { useState, MouseEventHandler } from "react"; import { useNavigate } from "react-router-dom"; -import { useRecoilValue } from "recoil"; -import Tooltip from "../../utils/Tooltip"; -import userState from "../../../atom/userState"; -import "../../../asset/css/Tags.css"; -import { useApi } from "../../../hook/useApi"; import { AxiosResponse } from "axios"; +import { TagType } from "~/type/TagType"; +import { useRecoilValue } from "recoil"; +import Tooltip from "~/component/utils/Tooltip"; +import { userAtom } from "~/atom/userAtom"; +import { useApi } from "~/hook/useApi"; -import minusicon from "../../../asset/img/tag_minus_white.svg"; -import trashicon from "../../../asset/img/trash_white.svg"; +import minusicon from "~/asset/img/tag_minus_white.svg"; +import trashicon from "~/asset/img/trash_white.svg"; +import "~/asset/css/Tags.css"; type TagProps = TagType & { tagData: TagType[]; @@ -29,7 +28,7 @@ const Tag = ({ setTagData, }: TagProps) => { const navigate = useNavigate(); - const currentLogin = useRecoilValue(userState); + const { userName } = useRecoilValue(userAtom); const [clickDeleteTag, setClickDeleteTag] = useState(false); const [icon, setIcon] = useState(minusicon); const { request } = useApi("delete", `/tags/sub/${id}`); @@ -56,11 +55,11 @@ const Tag = ({ const isType = () => { if (type === "super") return "super"; - else if (login === currentLogin.userName) return "my-sub"; + else if (login === userName) return "my-sub"; return "sub"; }; - const isMysub = login === currentLogin.userName; + const isMysub = login === userName; const onClickImage: MouseEventHandler = e => { e.stopPropagation(); diff --git a/src/component/eLibraryIn42Box/ELibraryHeader.jsx b/src/component/eLibraryIn42Box/ELibraryHeader.tsx similarity index 79% rename from src/component/eLibraryIn42Box/ELibraryHeader.jsx rename to src/component/eLibraryIn42Box/ELibraryHeader.tsx index 62194347..7bc0893b 100644 --- a/src/component/eLibraryIn42Box/ELibraryHeader.jsx +++ b/src/component/eLibraryIn42Box/ELibraryHeader.tsx @@ -1,21 +1,18 @@ import { useState } from "react"; import { useRecoilValue } from "recoil"; import { Link } from "react-router-dom"; -import userState from "../../atom/userState"; -import Logo from "../../asset/img/jiphyeonjeon_logo_without_text.svg"; +import { userAtom } from "~/atom/userAtom"; +import Logo from "~/asset/img/jiphyeonjeon_logo_without_text.svg"; -const ELibraryHeader = ({ setModalOpened }) => { - const { isLogin } = useRecoilValue(userState); +type Props = { + setModalOpened: (value: boolean) => void; +}; + +const ELibraryHeader = ({ setModalOpened }: Props) => { + const { isLogin } = useRecoilValue(userAtom); const [isFixed, setFixed] = useState(false); - const stickyHeader = () => { - if (window.pageYOffset > 140) { - setFixed(true); - } else { - setFixed(false); - } - }; - window.onscroll = stickyHeader; + window.onscroll = () => setFixed(window.scrollY > 140); return ( <> diff --git a/src/component/login/Logout.tsx b/src/component/login/Logout.tsx index 0c3b606f..78422952 100644 --- a/src/component/login/Logout.tsx +++ b/src/component/login/Logout.tsx @@ -1,25 +1,16 @@ import { useEffect } from "react"; import { Navigate } from "react-router-dom"; import { useResetRecoilState } from "recoil"; -import { usePostAuthLogout } from "../../api/auth/usePostAuthLogout"; -import userState from "../../atom/userState"; +import { usePostAuthLogout } from "~/api/auth/usePostAuthLogout"; +import { userAtom } from "~/atom/userAtom"; const Logout = () => { - const resetState = useResetRecoilState(userState); + const resetUser = useResetRecoilState(userAtom); const requestLogout = usePostAuthLogout(); - useEffect(() => { - requestLogout(() => { - resetState(); - window.localStorage.removeItem("user"); - }); - }, []); + useEffect(() => requestLogout(resetUser), []); - return ( - <> - - - ); + return ; }; export default Logout; diff --git a/src/component/mypage/EditEmailOrPassword.tsx b/src/component/mypage/EditEmailOrPassword.tsx index b12b90ce..f04c0f49 100644 --- a/src/component/mypage/EditEmailOrPassword.tsx +++ b/src/component/mypage/EditEmailOrPassword.tsx @@ -1,11 +1,13 @@ -import { useState, useMemo, ChangeEventHandler, FormEventHandler } from "react"; +import { useState, ChangeEventHandler, FormEventHandler } from "react"; +import { useRecoilValue } from "recoil"; import { useNavigate, useParams } from "react-router-dom"; -import { usePatchUsersMyupdate } from "../../api/users/usePatchUsersMyupdate"; -import { useNewDialog } from "../../hook/useNewDialog"; -import Image from "../utils/Image"; -import { registerRule } from "../../constant/validate"; -import arrowLeft from "../../asset/img/arrow_left_black.svg"; -import "../../asset/css/EditEmailOrPassword.css"; +import { usePatchUsersMyupdate } from "~/api/users/usePatchUsersMyupdate"; +import { useNewDialog } from "~/hook/useNewDialog"; +import Image from "~/component/utils/Image"; +import { registerRule } from "~/constant/validate"; +import arrowLeft from "~/asset/img/arrow_left_black.svg"; +import { userAtom } from "~/atom/userAtom" +import "~/asset/css/EditEmailOrPassword.css"; function EditEmailOrPassword() { const { mode } = useParams(); @@ -17,10 +19,7 @@ function EditEmailOrPassword() { check: "", }); - const userInfo = useMemo( - () => JSON.parse(window.localStorage.getItem("user") || "{}"), - [], - ); + const { email } = useRecoilValue(userAtom); const onChangeInput: ChangeEventHandler = e => { const { value } = e.currentTarget; @@ -60,7 +59,7 @@ function EditEmailOrPassword() {
- {`${userInfo ? userInfo.email : "-"}님의, `} + {`${email ?? "-"}님의, `} {`${modeStringKorean} 변경 페이지입니다`}
{mode === "email" ? ( @@ -68,7 +67,7 @@ function EditEmailOrPassword() {
현재 이메일 - {userInfo ? userInfo.email : "-"} + {email ?? "-"}
diff --git a/src/component/mypage/MyRentInfo/MyRent.tsx b/src/component/mypage/MyRentInfo/MyRent.tsx index 09c43651..0cce2589 100644 --- a/src/component/mypage/MyRentInfo/MyRent.tsx +++ b/src/component/mypage/MyRentInfo/MyRent.tsx @@ -3,10 +3,11 @@ import RentHistory from "./RentHistory"; import RentedOrReservedBooks from "./RentedOrReservedBooks"; import InquireBoxTitle from "~/component/utils/InquireBoxTitle"; import Book from "~/asset/img/admin_icon.svg"; +import { useRecoilValue } from "recoil" +import { userIdAtom } from "~/atom/userAtom" const MyRent = () => { - const user = window.localStorage.getItem("user"); - const userId = user && JSON.parse(user).id; + const userId = useRecoilValue(userIdAtom); const { userInfo } = useGetUsersSearchId({ userId }); return ( diff --git a/src/component/mypage/MyReservation.tsx b/src/component/mypage/MyReservation.tsx index ed52eec3..31d488f0 100644 --- a/src/component/mypage/MyReservation.tsx +++ b/src/component/mypage/MyReservation.tsx @@ -1,11 +1,12 @@ -import { useGetUsersSearchId } from "../../api/users/useGetUsersSearchId"; +import { useGetUsersSearchId } from "~/api/users/useGetUsersSearchId"; import RentedOrReservedBooks from "./MyRentInfo/RentedOrReservedBooks"; -import InquireBoxTitle from "../utils/InquireBoxTitle"; -import Reserve from "../../asset/img/list-check-solid.svg"; +import InquireBoxTitle from "~/component/utils/InquireBoxTitle"; +import Reserve from "~/asset/img/list-check-solid.svg"; +import { useRecoilValue } from "recoil" +import { userIdAtom } from "~/atom/userAtom" const MyReservation = () => { - const user = window.localStorage.getItem("user"); - const userId = user && JSON.parse(user).id; + const userId = useRecoilValue(userIdAtom); const { userInfo } = useGetUsersSearchId({ userId }); return ( diff --git a/src/component/mypage/Mypage.tsx b/src/component/mypage/Mypage.tsx index 7a1fcb8f..ee778265 100644 --- a/src/component/mypage/Mypage.tsx +++ b/src/component/mypage/Mypage.tsx @@ -13,6 +13,8 @@ import InquireBoxTitle from "../utils/InquireBoxTitle"; import getErrorMessage from "../../constant/error"; import Login from "../../asset/img/login_icon_white.svg"; import "../../asset/css/Mypage.css"; +import { useRecoilValue } from "recoil"; +import { userIdAtom } from "~/atom/userAtom" const Mypage = () => { const { currentTab, changeTab } = useTabFocus(0, myPageTabList); @@ -23,8 +25,7 @@ const Mypage = () => { myReservation: , myReview: , }; - const user = window.localStorage.getItem("user"); - const userId = user && JSON.parse(user).id; + const userId = useRecoilValue(userIdAtom); const { userInfo } = useGetUsersSearchId({ userId }); const [deviceMode, setDeviceMode] = useState("desktop"); diff --git a/src/component/search/Category.tsx b/src/component/search/Category.tsx new file mode 100644 index 00000000..2b8040cd --- /dev/null +++ b/src/component/search/Category.tsx @@ -0,0 +1,34 @@ +import { useSearchParams } from "react-router-dom"; + +type CategoryProps = { + isSelected: boolean; + name: string; + count: number; +}; + +const Category = ({ isSelected, name, count }: CategoryProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const changeFilter = () => { + searchParams.set("category", name); + setSearchParams(searchParams); + }; + + return ( + + ); +}; + +export default Category; diff --git a/src/component/search/CategoryFilter.tsx b/src/component/search/CategoryFilter.tsx index 620d21e5..419edf5d 100644 --- a/src/component/search/CategoryFilter.tsx +++ b/src/component/search/CategoryFilter.tsx @@ -1,226 +1,110 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; import Image from "../utils/Image"; +import Category from "./Category"; import ArrLeftGray from "../../asset/img/arrow_left_gray.svg"; import ArrLeftBlack from "../../asset/img/arrow_left_black.svg"; import ArrRightGray from "../../asset/img/arrow_right_gray.svg"; import ArrRightBlack from "../../asset/img/arrow_right_black.svg"; import "../../asset/css/CategoryFilter.css"; -const MARGIN_OF_CATEGORY_BUTTON = 36; -const EPSILON = 1; - -type PreCategoryProps = { - startOfScroll: boolean; +type Props = { + selectedCategory: number; + categoryList: { name: string; count: number }[]; }; -const PreCategory = ({ startOfScroll }: PreCategoryProps) => { - const scrollToPre = () => { - const categories = document.querySelector(".categories"); - if (categories) { - const categoriesScrollX = categories.scrollLeft; - - const categoryButton = document.getElementsByClassName("category-button"); - const categoryButtonWidth = Array.from(categoryButton).map( - items => items.clientWidth + MARGIN_OF_CATEGORY_BUTTON, - ); - - let sumOfCategory = 0; - // eslint-disable-next-line no-plusplus - for (let index = 0; index < categoryButtonWidth.length; index++) { - if ( - sumOfCategory + categoryButtonWidth[index] + EPSILON >= - categoriesScrollX - ) { - break; - } - sumOfCategory += categoryButtonWidth[index]; - } - categories.scrollTo({ left: sumOfCategory, top: 0, behavior: "smooth" }); - } - }; - - return ( - - ); -}; - -type NextCategoryProps = { - endOfScroll: boolean; -}; - -const NextCategory = ({ endOfScroll }: NextCategoryProps) => { - const scrollToNext = () => { - const categories = document.querySelector(".categories"); - if (categories) { - const categoriesScrollX = categories.scrollLeft; - const categoriesOffsetWidth = categories.offsetWidth; - const categoriesScrollWidth = categories.scrollWidth; - const endOfScrollWidth = categoriesScrollWidth - categoriesOffsetWidth; - - const categoryButton = document.getElementsByClassName("category-button"); - const categoryButtonWidth = Array.from(categoryButton).map( - items => items.clientWidth + MARGIN_OF_CATEGORY_BUTTON, - ); - - let sumOfCategory = 0; - // eslint-disable-next-line no-plusplus - for (let index = 0; index < categoryButtonWidth.length; index++) { - sumOfCategory += categoryButtonWidth[index]; - if (sumOfCategory - EPSILON > categoriesScrollX) { - break; - } - } - categories.scrollTo({ - left: - sumOfCategory > endOfScrollWidth ? endOfScrollWidth : sumOfCategory, - top: 0, - behavior: "smooth", +const CategoryFilter = ({ selectedCategory, categoryList }: Props) => { + const categoriesRef = useRef(null); + // categoriesRef의 스크롤이 처음이거나 끝인지 판단하는 state + const [scrollPosition, setScrollPosition] = useState({ + isScrollAtStart: true, + isScrollAtEnd: true, + }); + + const updateScrollPosition = () => { + const target = categoriesRef.current; + if (target) { + const endOfScroll = target.scrollWidth - target.offsetWidth; + setScrollPosition({ + isScrollAtStart: target.scrollLeft === 0, + isScrollAtEnd: target.scrollLeft === endOfScroll, }); } }; - return ( - - ); -}; - -type CategoryProps = { - userWord: string; - userSort: string; - userCate: number; - categoryIndex: number; - categoryName: string; - categoryNum: number; -}; - -const Category = ({ - userWord, - userSort, - userCate, - categoryIndex, - categoryName, - categoryNum, -}: CategoryProps) => { - const navigate = useNavigate(); - - const changeFilter = () => { - navigate( - `?search=${userWord}&page=${1}&category=${categoryName}&sort=${userSort}`, - ); - }; - - return ( - - ); -}; - -type CategoryFilterProps = { - userWord: string; - userSort: string; - userCate: number; - entireCate: { name: string; count: number }[]; -}; - -const CategoryFilter = ({ - userWord, - userSort, - userCate, - entireCate, -}: CategoryFilterProps) => { - const [startOfScroll, setStartOfScroll] = useState(true); - const [endOfScroll, setEndOfScroll] = useState(true); - - const setScrollState = () => { - const categories = document.querySelector(".categories"); - if (categories) { - const categoriesScrollX = categories.scrollLeft; - const categoriesOffsetWidth = categories.offsetWidth; - const categoriesScrollWidth = categories.scrollWidth; - const endOfScrollWidth = categoriesScrollWidth - categoriesOffsetWidth; - - if (categoriesScrollX === 0) { - setStartOfScroll(true); - } else { - setStartOfScroll(false); - } - - if (categoriesScrollX === endOfScrollWidth) { - setEndOfScroll(true); - } else { - setEndOfScroll(false); - } - } - }; - useEffect(() => { - setScrollState(); - window.addEventListener("resize", setScrollState); + updateScrollPosition(); + // 윈도우 창 크기가 변할 때마다 updateScrollPosition 함수를 실행 + window.addEventListener("resize", updateScrollPosition); return () => { - window.removeEventListener("resize", setScrollState); + window.removeEventListener("resize", updateScrollPosition); }; - }, [setScrollState]); + }, [categoriesRef]); + + const scrollTo = (direction: "prev" | "next") => { + if (categoriesRef.current) { + const childElements = Array.from(categoriesRef.current.children); + + // categories의 왼쪽 경계값 기준 + const edge = categoriesRef.current.getBoundingClientRect().left; + const target = + direction === "prev" + ? childElements + // 경계보다 작은 left값을 가진 마지막 요소 + .filter(e => Math.ceil(e.getBoundingClientRect().left) < edge) + .slice(-1)[0] + : childElements + // 경계보다 큰 left값을 가진 첫번째 요소 + .filter(e => Math.floor(e.getBoundingClientRect().left) > edge) + .slice()[0]; + + // 해당 요소로 가로 스크롤 부드럽게 이동 + target?.scrollIntoView({ + inline: "start", + block: "nearest", + behavior: "smooth", + }); + } + }; return (
- -
- {entireCate.map((items, index) => ( + +
+ {categoryList.map((items, index) => ( ))}
- +
diff --git a/src/component/search/Search.tsx b/src/component/search/Search.tsx index a2dd06d5..c7da788f 100644 --- a/src/component/search/Search.tsx +++ b/src/component/search/Search.tsx @@ -68,10 +68,8 @@ const Search = () => { />
diff --git a/src/component/utils/HandleReview.tsx b/src/component/utils/HandleReview.tsx index 211305b8..2ac47cc4 100644 --- a/src/component/utils/HandleReview.tsx +++ b/src/component/utils/HandleReview.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; -import { dateFormat } from "../../util/date"; +import { dateFormat } from "~/util/date"; import Image from "./Image"; -import UserEdit from "../../asset/img/edit.svg"; -import DeleteButton from "../../asset/img/x_button.svg"; -import "../../asset/css/Review.css"; -import { Review } from "../../type"; -import { useNewDialog } from "../../hook/useNewDialog"; -import userState from "../../atom/userState"; +import UserEdit from "~/asset/img/edit.svg"; +import DeleteButton from "~/asset/img/x_button.svg"; +import "~/asset/css/Review.css"; +import { Review } from "~/type"; +import { useNewDialog } from "~/hook/useNewDialog"; +import { userAtom } from "~/atom/userAtom"; import { useRecoilValue } from "recoil"; -import { usePutReviewsReviewsId } from "../../api/reviews/usePutReviewsReviewsId"; +import { usePutReviewsReviewsId } from "~/api/reviews/usePutReviewsReviewsId"; import { Link } from "react-router-dom"; type Props = { @@ -19,8 +19,8 @@ type Props = { const HandleReview = ({ type, review, deleteReview }: Props) => { const [isEditMode, setEditMode] = useState(false); - const user = useRecoilValue(userState); - const hasPermissionToEdit = user && user.userName === review.nickname; + const { userName } = useRecoilValue(userAtom); + const hasPermissionToEdit = userName === review.nickname; const startEditMode = () => setEditMode(true); const finishEditMode = () => setEditMode(false); diff --git a/src/component/utils/HeaderDefault.tsx b/src/component/utils/HeaderDefault.tsx index b26950d0..b19ddb31 100644 --- a/src/component/utils/HeaderDefault.tsx +++ b/src/component/utils/HeaderDefault.tsx @@ -1,17 +1,17 @@ import { useRecoilValue } from "recoil"; import { Link } from "react-router-dom"; import { basicGnbMenu } from "~/constant/headerMenu"; -import userState from "~/atom/userState"; import Image from "./Image"; import HeaderDefaultLNB from "./HeaderDefaultLNB"; import Logo from "~/asset/img/jiphyeonjeon_logo.svg"; import "~/asset/css/HeaderDefault.css"; import SearchRanking from "./SearchRanking"; +import { isUserAuthedAtom } from "~/atom/userAtom"; const HeaderDefault = () => { - const user = useRecoilValue(userState); + const isAuthed = useRecoilValue(isUserAuthedAtom); - const gnbMenu = user.isLogin + const gnbMenu = isAuthed ? basicGnbMenu.slice(0, basicGnbMenu.length - 1) // basicGnbMenu의 마지막 요소는 "로그인", 이미 로그인 상태면 제외 : basicGnbMenu; @@ -35,7 +35,7 @@ const HeaderDefault = () => { {menu.text} ))} - {user.isLogin && } + {isAuthed && } diff --git a/src/component/utils/HeaderDefaultLNB.tsx b/src/component/utils/HeaderDefaultLNB.tsx index bf90ee6b..c1f2caf3 100644 --- a/src/component/utils/HeaderDefaultLNB.tsx +++ b/src/component/utils/HeaderDefaultLNB.tsx @@ -2,17 +2,17 @@ import { useEffect, useState } from "react"; import { useRecoilValue } from "recoil"; import { Link, useLocation } from "react-router-dom"; import { adminLnbMenu, loginLnbMenu } from "~/constant/headerMenu"; -import userState from "~/atom/userState"; import Image from "./Image"; import User from "~/asset/img/Uniconlabs.png"; import ToggleUser from "~/asset/img/UniconlabsFill.png"; import ToggleDownArrow from "~/asset/img/caret-down_DaveGandy.png"; import DownArrow from "~/asset/img/drop-down_Freepik.png"; import "~/asset/css/HeaderDefaultLNB.css"; +import { userAtom } from "~/atom/userAtom" const HeaderDefaultLNB = () => { const [isLNBOpened, setIsLNBOpened] = useState(false); - const user = useRecoilValue(userState); + const user = useRecoilValue(userAtom); const location = useLocation(); useEffect(() => { diff --git a/src/component/utils/HeaderModal.tsx b/src/component/utils/HeaderModal.tsx index 12c5e95e..534e54d4 100644 --- a/src/component/utils/HeaderModal.tsx +++ b/src/component/utils/HeaderModal.tsx @@ -1,22 +1,22 @@ import { useRecoilValue } from "recoil"; import { Link } from "react-router-dom"; -import userState from "../../atom/userState"; +import { userAtom } from "~/atom/userAtom"; import Image from "./Image"; import { basicGnbMenu, adminLnbMenu, loginLnbMenu, -} from "../../constant/headerMenu"; -import CloseButton from "../../asset/img/x_button_grey.svg"; -import User from "../../asset/img/Freepik_user.png"; -import "../../asset/css/HeaderModal.css"; +} from "~/constant/headerMenu"; +import CloseButton from "~/asset/img/x_button_grey.svg"; +import User from "~/asset/img/Freepik_user.png"; +import "~/asset/css/HeaderModal.css"; type Props = { setHeaderModal(...args: unknown[]): unknown; }; const HeaderModal = ({ setHeaderModal }: Props) => { - const user = useRecoilValue(userState); + const user = useRecoilValue(userAtom); const closeHeaderModal = () => { setHeaderModal(false); }; diff --git a/src/component/utils/ManagementSearchBar.tsx b/src/component/utils/ManagementSearchBar.tsx index 59e9deb9..cef10e33 100644 --- a/src/component/utils/ManagementSearchBar.tsx +++ b/src/component/utils/ManagementSearchBar.tsx @@ -6,6 +6,8 @@ import { useState, } from "react"; import SearchBar from "~/component/utils/SearchBar"; +import Image from "./Image"; +import BarcodeIcon from "~/asset/img/barcode.svg"; type Props = { width: "banner" | "center" | "short" | "long"; @@ -57,7 +59,7 @@ const ManagementSearchBar = ({ className="search-bar__button barcode" onClick={onClickBarcodeButton} > - 바코드 + 바코드 ) : null} diff --git a/src/component/utils/NotFound.jsx b/src/component/utils/NotFound.jsx index 195c97c8..085801c8 100644 --- a/src/component/utils/NotFound.jsx +++ b/src/component/utils/NotFound.jsx @@ -1,13 +1,54 @@ +import { useSpring, animated } from '@react-spring/web'; import "../../asset/css/NotFound.css"; - const NotFound = () => { + const [springs, api] = useSpring(() => ({ + from: { opacity: 0, transform: 'translate3d(0px, 0px, 0px)' }, + config: { duration: 500 } // 애니메이션 지속 시간 설정 + })); + + const froms = [ + { x: 0, y: 0 }, + { x: window.innerWidth, y: 0 }, + { x: 0, y: window.innerHeight }, + { x: window.innerWidth, y: window.innerHeight }, + ]; + + const getRandomFromIndex = (from, to) => { + const min = Math.ceil(from); + const max = Math.floor(to); + return Math.floor(Math.random() * (max - min)) + min; + } + + const handleClick = (event) => { + api.start({ + to: { + opacity: 1, // 불투명도를 1로 설정 + transform: `translate3d(${event.clientX}px, ${event.clientY}px, 0px)` // 위치 업데이트 + }, + // 애니메이션 시작시 불투명도를 0으로 설정 및 위치 설정 froms 배열에서 랜덤으로 from 선택되도록 수정 + from: { + opacity: 0, + transform: `translate3d(${froms[getRandomFromIndex(0, froms.length)].x}px, ${froms[getRandomFromIndex(0, froms.length)].y}px, 0px)` + }, + reset: true // 매 클릭마다 애니메이션 리셋 + }); + }; + return ( -
+ <> +
+ +
404
Not Found
-
+ ); }; diff --git a/src/component/utils/SearchBar.tsx b/src/component/utils/SearchBar.tsx index b792fbec..faf7688a 100644 --- a/src/component/utils/SearchBar.tsx +++ b/src/component/utils/SearchBar.tsx @@ -11,11 +11,19 @@ export type Props = ComponentProps<"form"> & { const SearchBar = ({ width = "banner", className = "", + onSubmit, children, ...rest }: Props) => { return ( - + { + e.preventDefault(); + onSubmit && onSubmit(e); + }} + > {children} ); diff --git a/src/config/sentry.ts b/src/config/sentry.ts new file mode 100644 index 00000000..f91c30cc --- /dev/null +++ b/src/config/sentry.ts @@ -0,0 +1,38 @@ +// reference : https://docs.sentry.io/platforms/javascript/guides/react/features/react-router/ + +import { useEffect } from "react"; +import * as Sentry from "@sentry/react"; +import { + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from "react-router-dom"; + +export const sentryInit = () => { + Sentry.init({ + dsn: import.meta.env.REACT_APP_SENTRY, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + new Sentry.Replay({ + maskAllText: false, + blockAllMedia: false, + }), + ], + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + environment: import.meta.env.REACT_APP_ENV, + }); +}; + +export const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); diff --git a/src/env.d.ts b/src/env.d.ts index 7154493d..07d9559f 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -12,9 +12,17 @@ interface ImportMetaEnv { /** 건의사항 시트 URL */ readonly REACT_APP_SUGGESTION: string; - readonly REACT_APP_SENTRY:string; + + /** 실행 환경 */ + readonly REACT_APP_ENV: "development" | "production"; + + /** 로그 및 통계를 위한 센트리, 구글애널리틱스 */ + readonly REACT_APP_SENTRY: string; + readonly REACT_APP_GA_ID: string; } interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module "ga-gtag"; diff --git a/src/hook/usePermission.ts b/src/hook/usePermission.ts index 04f4b5b2..ac06e35f 100644 --- a/src/hook/usePermission.ts +++ b/src/hook/usePermission.ts @@ -1,9 +1,9 @@ import { useRecoilValue } from "recoil"; +import { userAtom } from "~/atom/userAtom" import { useNewDialog } from "~/hook/useNewDialog"; -import userState from "~/atom/userState"; export const usePermission = () => { - const user = useRecoilValue(userState); + const user = useRecoilValue(userAtom); const isLoggined = user !== null; const isAdmin = user?.isAdmin; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index ec2585e8..00000000 --- a/src/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} diff --git a/src/index.tsx b/src/index.tsx index 7628acc5..a8b03705 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,20 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RecoilRoot } from "recoil"; -import * as Sentry from "@sentry/react"; -import { BrowserTracing } from "@sentry/tracing"; +import { sentryInit } from "./config/sentry"; import App from "./App"; -import "./index.css"; -Sentry.init({ - dsn: import.meta.env.REACT_APP_SENTRY, - integrations: [new BrowserTracing()], - tracesSampleRate: 1.0, - environment: - import.meta.env.REACT_APP_API === "http://localhost:3000/api" - ? "development" - : "production", -}); +sentryInit(); createRoot(document.getElementById("root")!).render( diff --git a/src/type/BookInfo.ts b/src/type/BookInfo.ts index 2c555af2..7ee8b580 100644 --- a/src/type/BookInfo.ts +++ b/src/type/BookInfo.ts @@ -10,7 +10,6 @@ export type BookInfo = { publisher: string; publishedAt?: string; image?: string; - koreanDemicalClassification?: string; donator?: string; books?: Book[]; }; diff --git a/src/type/User.ts b/src/type/User.ts index 36a7e551..5bb42d3c 100644 --- a/src/type/User.ts +++ b/src/type/User.ts @@ -19,8 +19,8 @@ export type User = { export type UserState = { isLogin: boolean; id: number; - userName: string; - email: string; + userName: string | undefined; + email: string | undefined; isAdmin: boolean; - expire: string; // ISO String + expire: string | undefined; // ISO String }; diff --git a/src/util/axios.ts b/src/util/axios.ts index 28f3e0c2..d8aa8ff7 100644 --- a/src/util/axios.ts +++ b/src/util/axios.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import * as Sentry from "@sentry/react"; const api = axios.create({ baseURL: `${import.meta.env.REACT_APP_API}`, @@ -14,3 +15,8 @@ const axiosPromise = (method: string, url: string, data?: unknown) => { }; export default axiosPromise; + +api.interceptors.response.use( + response => response, + error => Sentry.captureException(error), +); diff --git a/src/util/localStorageEffect.ts b/src/util/localStorageEffect.ts new file mode 100644 index 00000000..1fc4dd2d --- /dev/null +++ b/src/util/localStorageEffect.ts @@ -0,0 +1,21 @@ +import { AtomEffect } from "recoil"; + +/** + * recoil atom 상태를 localStorage에 연동합니다. + * + * {@link https://recoiljs.org/docs/guides/atom-effects/#local-storage-persistence | recoil 공식 문서 } + */ +export const localStorageEffect = + (key: string): AtomEffect => + ({ setSelf, onSet }) => { + const savedValue = localStorage.getItem(key); + if (savedValue != null) { + setSelf(JSON.parse(savedValue)); + } + + onSet((newValue, _, isReset) => { + isReset + ? localStorage.removeItem(key) + : localStorage.setItem(key, JSON.stringify(newValue)); + }); + }; diff --git a/vite.config.ts b/vite.config.ts index 101cd41a..deea2e9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,5 +31,7 @@ export default defineConfig(({ mode }) => { /** @see https://vitejs.dev/config/build-options.html#build-outdir */ build: { outDir: "build" }, + + base: "./", }; });