diff --git a/next.config.ts b/next.config.ts index dca67ead..121b43bc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,13 +1,14 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + images: { remotePatterns: [ { protocol: 'https', - hostname:'www.thecocktaildb.com' - } - ] + hostname: 'www.thecocktaildb.com', + }, + ], }, env: { NPUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, diff --git a/package-lock.json b/package-lock.json index bcea2d9b..497c4796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-intersection-observer": "^9.16.0", "react-use": "^17.6.0" }, "devDependencies": { @@ -8243,6 +8244,21 @@ "react-dom": ">=16" } }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 7654fec6..30cba0cb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-intersection-observer": "^9.16.0", "react-use": "^17.6.0" }, "devDependencies": { @@ -61,4 +62,4 @@ }, "homepage": "https://github.com/prgrms-web-devcourse-final-project/WEB5_6_HaeDokCoding_FE#readme", "description": "" -} \ No newline at end of file +} diff --git a/src/app/mypage/my-active/my-like/page.tsx b/src/app/mypage/my-active/my-like/page.tsx index 2544af73..8c3d624e 100644 --- a/src/app/mypage/my-active/my-like/page.tsx +++ b/src/app/mypage/my-active/my-like/page.tsx @@ -8,6 +8,6 @@ export const metadata: Metadata = { }; function Page() { - // return ; + return ; } export default Page; diff --git a/src/domains/community/hook/useComment.ts b/src/domains/community/hook/useComment.ts index 6903a277..15b8a63c 100644 --- a/src/domains/community/hook/useComment.ts +++ b/src/domains/community/hook/useComment.ts @@ -23,7 +23,7 @@ export function useComments(postId: number, user: User | null, accessToken: stri fetchData(); }, [fetchData]); - const handleUpdateComment = async (commentId: number, postId: number, content: string) => { + const handleUpdateComment = async (commentId: number, content: string) => { if (!user) { alert('로그인이 필요합니다'); return; @@ -43,7 +43,7 @@ export function useComments(postId: number, user: User | null, accessToken: stri } }; - const handleAskDeleteComment = (commentId: number, postId: number) => { + const handleAskDeleteComment = (commentId: number) => { setDeleteTarget({ commentId, postId }); }; diff --git a/src/domains/mypage/components/ToggleBtn.tsx b/src/domains/mypage/components/ToggleBtn.tsx index 4443525c..d066618e 100644 --- a/src/domains/mypage/components/ToggleBtn.tsx +++ b/src/domains/mypage/components/ToggleBtn.tsx @@ -16,7 +16,6 @@ function ToggleBtn() { credentials: 'include', }); const json = await res.json(); - console.log(json); setIsAlarm(json.data.enabled); } catch { console.error(); diff --git a/src/domains/mypage/components/pages/my-active/MyComment.tsx b/src/domains/mypage/components/pages/my-active/MyComment.tsx index c52be8b8..daf1e549 100644 --- a/src/domains/mypage/components/pages/my-active/MyComment.tsx +++ b/src/domains/mypage/components/pages/my-active/MyComment.tsx @@ -2,18 +2,26 @@ import { getApi } from '@/app/api/config/appConfig'; import { CommentType } from '@/domains/community/types/post'; import CommentList from '@/domains/shared/components/comment/CommentList'; +import { useAuthStore } from '@/domains/shared/store/auth'; import { useEffect, useState } from 'react'; +import { useShallow } from 'zustand/shallow'; function MyComment() { + const { user } = useAuthStore( + useShallow((state) => ({ + user: state.user, + })) + ); + const [myComment, setMyComment] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading] = useState(false); + const fetchComment = async () => { const res = await fetch(`${getApi}/me/comments`, { method: 'GET', credentials: 'include', }); const json = await res.json(); - console.log(json); setMyComment(json.data.items); }; @@ -23,13 +31,18 @@ function MyComment() { return (
- {/* {CommentList.length !== 0 ? ( - + {CommentList.length !== 0 ? ( + ) : (

작성한 댓글이 없습니다.

- )} */} + )}
); } diff --git a/src/domains/mypage/components/pages/my-active/MyLike.tsx b/src/domains/mypage/components/pages/my-active/MyLike.tsx index 7785690f..5f283250 100644 --- a/src/domains/mypage/components/pages/my-active/MyLike.tsx +++ b/src/domains/mypage/components/pages/my-active/MyLike.tsx @@ -3,30 +3,31 @@ import { getApi } from '@/app/api/config/appConfig'; import PostCard from '@/domains/community/main/PostCard'; import { useEffect, useState } from 'react'; -interface MyLike { - postId: number; - title: string; - likedAt: Date; - posetCreatedAt: Date; -} - function MyLike() { - const [myLike, setMyLike] = useState([]); + const [myLike, setMyLike] = useState([]); const [isLoading, setIsLoading] = useState(false); const fetchLike = async () => { - const res = await fetch(`${getApi}/me/likes/posts`, { + const res = await fetch(`${getApi}/me/likes`, { method: 'GET', credentials: 'include', }); const json = await res.json(); - // setMyLike(json.data.items); + setMyLike(json.data.items); }; useEffect(() => { fetchLike(); }, []); - // return ; + return ( +
+ {myLike.length > 0 ? ( + + ) : ( +
아직 좋아요를 누른 글이 없습니다
+ )} +
+ ); } export default MyLike; diff --git a/src/domains/mypage/components/pages/my-active/MyPost.tsx b/src/domains/mypage/components/pages/my-active/MyPost.tsx index 24515b32..c17c6dc8 100644 --- a/src/domains/mypage/components/pages/my-active/MyPost.tsx +++ b/src/domains/mypage/components/pages/my-active/MyPost.tsx @@ -12,6 +12,7 @@ function MyPost() { credentials: 'include', }); const json = await res.json(); + console.log(json); setMyPost(json.data.items); }; diff --git a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx index d0b2431f..512248bd 100644 --- a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx +++ b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import Alarm from '../../Alarm'; import { getApi } from '@/app/api/config/appConfig'; +import TextButton from '@/shared/components/button/TextButton'; interface MyAlarm { notificationId: number; @@ -29,6 +30,9 @@ function MyAlarm() { return (
+
+ 전체삭제 +
{myAlarm.length !== 0 ? ( myAlarm.map(({ notificationId, title, content }) => ( diff --git a/src/domains/mypage/components/pages/my-bar/MyBar.tsx b/src/domains/mypage/components/pages/my-bar/MyBar.tsx index bb96e8b2..3bceb240 100644 --- a/src/domains/mypage/components/pages/my-bar/MyBar.tsx +++ b/src/domains/mypage/components/pages/my-bar/MyBar.tsx @@ -1,20 +1,24 @@ 'use client'; import { getApi } from '@/app/api/config/appConfig'; +import { abvMap } from '@/domains/mypage/utills/abvMap'; import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; +import TextButton from '@/shared/components/button/TextButton'; import Link from 'next/link'; import { useEffect, useState } from 'react'; interface MyCocktail { cocktailId: number; cocktailName: string; + cocktailNameKo: string; id: number; imageUrl: string; + alcoholStrength: string; } function MyBar() { const [myCocktail, setMyCocktail] = useState([]); const fetchData = async () => { - const res = await fetch(`${getApi}/me/bar`, { + const res = await fetch(`${getApi}/me/bar/detail`, { method: 'GET', credentials: 'include', }); @@ -28,26 +32,35 @@ function MyBar() { return (
+
+ 전체삭제 +
{myCocktail.length !== 0 ? (
- {myCocktail.map(({ cocktailId, cocktailName, imageUrl }) => ( - - - - ))} + {myCocktail.map( + ({ cocktailId, cocktailName, imageUrl, cocktailNameKo, alcoholStrength }) => { + const alcohol = abvMap(alcoholStrength); + return ( + + + + ); + } + )}
) : (
diff --git a/src/domains/mypage/main/MyNav.tsx b/src/domains/mypage/main/MyNav.tsx index fac49fd5..c0579e76 100644 --- a/src/domains/mypage/main/MyNav.tsx +++ b/src/domains/mypage/main/MyNav.tsx @@ -92,17 +92,9 @@ function MyNav() { role="tabpanel" aria-labelledby={`main-tab-${i}`} hidden={isActive !== i} - > - {/* 필요하면 여기 메인 탭별 콘텐츠 렌더 */} -
+ >
))} - {(isActive == 0 || isActive == 2) && ( - setIsDeleteAll(!isDeleteAll)}> - 전체삭제 - - )} - {isActive == 1 && (
); diff --git a/src/domains/mypage/utills/abvMap.ts b/src/domains/mypage/utills/abvMap.ts new file mode 100644 index 00000000..975ab58d --- /dev/null +++ b/src/domains/mypage/utills/abvMap.ts @@ -0,0 +1,17 @@ +export function abvMap(input: string) { + if (!input) return ''; + switch (input) { + case 'NON_ALCOHOLIC': + return '논 알콜'; + case 'WEAK': + return '약한 도수'; + case 'LIGHT': + return '가벼운 도수'; + case 'MEDIUM': + return '중간 도수'; + case 'STRONG': + return '센 도수'; + case 'VERY_STRONG': + return '매우 센 도수'; + } +} diff --git a/src/domains/recipe/api/RecipeFetch.tsx b/src/domains/recipe/api/RecipeFetch.tsx index 1fb008af..a86fecb5 100644 --- a/src/domains/recipe/api/RecipeFetch.tsx +++ b/src/domains/recipe/api/RecipeFetch.tsx @@ -2,7 +2,8 @@ import { getApi } from '@/app/api/config/appConfig'; import { Cocktail } from '../types/types'; -import { Dispatch, SetStateAction, useCallback } from 'react'; +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { useAuthStore } from '@/domains/shared/store/auth'; interface Props { setData: React.Dispatch>; @@ -12,6 +13,7 @@ interface Props { setHasNextPage: Dispatch>; SIZE?: number; } + // api/cocktais fetch용 export const RecipeFetch = ({ setData, @@ -21,6 +23,7 @@ export const RecipeFetch = ({ setHasNextPage, SIZE = 20, }: Props) => { + const user = useAuthStore(); const fetchData = useCallback(async () => { // 쿼리파라미터에 값 넣기 if (!hasNextPage) return; @@ -29,17 +32,38 @@ export const RecipeFetch = ({ if (typeof lastId === 'number') { url.searchParams.set('lastId', String(lastId)); } + url.searchParams.set('LastValue', String(lastId)); - const res = await fetch(url.toString(), { method: 'GET' }); - if (!res.ok) throw new Error('레시피 데이터 요청실패'); - - const json = await res.json(); - const list: Cocktail[] = json.data ?? []; + const recipeRes = await fetch(url.toString(), { + method: 'GET', + }); + if (!recipeRes.ok) throw new Error('데이터 요청 실패'); + const recipeJson = await recipeRes.json(); + const list: Cocktail[] = recipeJson.data ?? []; - // 중복 아이디 에러있어서 Map으로 Merge - setData((prev) => - Array.from(new Map([...prev, ...list].map((i) => [i.cocktailId, i])).values()) - ); + if (user) { + const keepRes = await fetch(`${getApi}/me/bar`, { + method: 'GET', + credentials: 'include', + }); + const bars = keepRes.ok ? ((await keepRes.json()).data ?? []) : []; + const favoriteIds = new Set(bars.map((m: { cocktailId: number }) => m.cocktailId)); + const merged = list.map((item) => ({ + ...item, + isFavorited: favoriteIds.has(item.cocktailId), + })); + setData((prev) => + Array.from( + new Map([...prev, ...merged].map((i) => [i.cocktailId, i])).values() + ) + ); + } else { + setData((prev) => + Array.from( + new Map([...prev, ...list].map((i) => [i.cocktailId, i])).values() + ) + ); + } if (list.length > 0) { setLastId(list[list.length - 1].cocktailId); diff --git a/src/domains/recipe/api/fetchRecipeComment.ts b/src/domains/recipe/api/fetchRecipeComment.ts new file mode 100644 index 00000000..627001ee --- /dev/null +++ b/src/domains/recipe/api/fetchRecipeComment.ts @@ -0,0 +1,64 @@ +import { getApi } from '@/app/api/config/appConfig'; +import { CommentType } from '@/domains/community/types/post'; + +export const getRecipeComment = async (cocktailId: number): Promise => { + try { + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { + method: 'GET', + cache: 'no-store', // 캐시 비활성화 + }); + const data = await res.json(); + + //삭제된 댓글은 제외 + const filteredComments = data.data.filter( + (comment: CommentType) => comment.status !== 'DELETED' + ); + + return filteredComments; + } catch (err) { + console.error('해당 글의 댓글 조회 실패', err); + return null; + } +}; + +export async function updateComment( + accessToken: string | null, + postId: number, + commentId: number, + content: string +): Promise { + console.log(postId, typeof postId); + const response = await fetch(`${getApi}/cocktails/${postId}/comments/${commentId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('서버 응답 에러:', errorText); + throw new Error(`댓글 수정 실패: ${response.status}`); + } +} + +export async function deleteRecipeComment( + accessToken: string | null, + cocktailId: number, + commentId: number +): Promise { + const response = await fetch(`${getApi}/cocktails/${cocktailId}/comments/${commentId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('서버 응답 에러:', errorText); + throw new Error(`댓글 수정 실패: ${response.status}`); + } +} diff --git a/src/domains/recipe/api/useRecipeComment.ts b/src/domains/recipe/api/useRecipeComment.ts new file mode 100644 index 00000000..d5aa6f6d --- /dev/null +++ b/src/domains/recipe/api/useRecipeComment.ts @@ -0,0 +1,106 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getApi } from '@/app/api/config/appConfig'; +import { User } from '@/domains/shared/store/auth'; +import { CommentType } from '@/domains/community/types/post'; +import { deleteRecipeComment, getRecipeComment, updateComment } from './fetchRecipeComment'; +import { useToast } from '@/shared/hook/useToast'; + +export function useRecipeComments( + cocktailId: number, + user: User | null, + accessToken: string | null +) { + const [comments, setComments] = useState(null); + const [isEnd, setIsEnd] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ + commentId: number; + cocktailId: number; + } | null>(null); + const { toastError } = useToast(); + + const fetchData = useCallback(async () => { + const data = await getRecipeComment(cocktailId); + if (!data) return; + setComments(data); + setIsEnd(false); + }, [cocktailId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleUpdateComment = async (commentId: number, content: string) => { + if (!user) { + toastError('로그인이 필요합니다'); + return; + } + try { + await updateComment(accessToken!, cocktailId, commentId, content); + setComments((prev) => + prev + ? prev.map((comment) => + comment.commentId === commentId ? { ...comment, content } : comment + ) + : prev + ); + } catch (err) { + console.error(err); + toastError('댓글 수정 중 오류가 발생했습니다.'); + } + }; + + const handleAskDeleteComment = (commentId: number) => { + setDeleteTarget({ commentId, cocktailId }); + }; + + const handleConfirmDelete = async () => { + if (!user) { + toastError('로그인이 필요합니다'); + return; + } + if (!deleteTarget) return; + + try { + await deleteRecipeComment(accessToken!, deleteTarget.cocktailId, deleteTarget.commentId); + setComments((prev) => + prev ? prev.filter((c) => c.commentId !== deleteTarget.commentId) : prev + ); + } catch (err) { + console.error(err); + } finally { + setDeleteTarget(null); + } + }; + + const loadMoreComments = async (lastCommentId: number) => { + if (isEnd || isLoading) return; + + setIsLoading(true); + try { + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments?lastId=${lastCommentId}`); + const newComments = await res.json(); + + if (newComments.data.length === 0) { + setIsEnd(true); + } else { + setComments((prev) => [...(prev ?? []), ...newComments.data]); + } + } finally { + setIsLoading(false); + } + }; + + return { + comments, + isEnd, + isLoading, + deleteTarget, + setDeleteTarget, + fetchData, + handleUpdateComment, + handleAskDeleteComment, + handleConfirmDelete, + loadMoreComments, + }; +} diff --git a/src/domains/recipe/components/details/BackBtn.tsx b/src/domains/recipe/components/details/BackBtn.tsx index b4e2a6dc..069522a7 100644 --- a/src/domains/recipe/components/details/BackBtn.tsx +++ b/src/domains/recipe/components/details/BackBtn.tsx @@ -10,6 +10,7 @@ function BackButton() { const url = sessionStorage.getItem('saveUrl'); if (!url) return; router.push(url); + sessionStorage.removeItem('listScrollY'); }; return ( diff --git a/src/domains/recipe/components/details/RecipeComment.tsx b/src/domains/recipe/components/details/RecipeComment.tsx index 57397d2d..2678fcd1 100644 --- a/src/domains/recipe/components/details/RecipeComment.tsx +++ b/src/domains/recipe/components/details/RecipeComment.tsx @@ -1,11 +1,94 @@ import CommentHeader from '@/domains/shared/components/comment/CommentHeader'; import CommentList from '@/domains/shared/components/comment/CommentList'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { useShallow } from 'zustand/shallow'; +import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; +import { useRecipeComments } from '../../api/useRecipeComment'; +import { getApi } from '@/app/api/config/appConfig'; +import { useToast } from '@/shared/hook/useToast'; + +interface Props { + cocktailId: number; +} + +function RecipeComment({ cocktailId }: Props) { + const { user, accessToken } = useAuthStore( + useShallow((state) => ({ + user: state.user, + accessToken: state.accessToken, + })) + ); + + const { toastInfo } = useToast(); + + const postRecipeComment = async (cocktailId: number, content: string) => { + if (!user?.id) { + toastInfo('로그인 후 이용 가능합니다'); + return; + } + + try { + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ content }), + }); + const text = await res.text(); + + if (!res.ok) { + toastInfo('댓글은 한 개만 작성가능합니다'); + return; + } + + const data = JSON.parse(text); + return data; + } catch (err) { + console.error(err); + } + }; + + const { + comments, + fetchData, + handleAskDeleteComment, + handleUpdateComment, + loadMoreComments, + isEnd, + isLoading, + deleteTarget, + handleConfirmDelete, + setDeleteTarget, + } = useRecipeComments(cocktailId, user, accessToken); -function RecipeComment() { return (
- {/* */} - {/* */} + + + {deleteTarget && ( + setDeleteTarget(null)} + onClose={() => setDeleteTarget(null)} + title="댓글 삭제" + description="정말 이 댓글을 삭제하시겠습니까?" + /> + )}
); } diff --git a/src/domains/recipe/components/main/Accordion.tsx b/src/domains/recipe/components/main/Accordion.tsx index 685053d0..771c5a1b 100644 --- a/src/domains/recipe/components/main/Accordion.tsx +++ b/src/domains/recipe/components/main/Accordion.tsx @@ -135,8 +135,6 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths const newUrl = `${pathname}?${queryString}`; router.push(newUrl); - // shallow routing으로 URL만 변경 - // window.history.pushState({ ...window.history.state, as: newUrl, url: newUrl }, '', newUrl); }; return ( diff --git a/src/domains/recipe/components/main/CocktailFilter.tsx b/src/domains/recipe/components/main/CocktailFilter.tsx index 5d2c4a0b..1941e240 100644 --- a/src/domains/recipe/components/main/CocktailFilter.tsx +++ b/src/domains/recipe/components/main/CocktailFilter.tsx @@ -1,10 +1,47 @@ +import { getApi } from '@/app/api/config/appConfig'; import SelectBox from '@/shared/components/select-box/SelectBox'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Dispatch, SetStateAction } from 'react'; +import { Cocktail } from '../../types/types'; + +interface Props { + cocktailsEA: string; + setData: Dispatch>; +} + +function CocktailFilter({ cocktailsEA, setData }: Props) { + const sortMap = { + 최신순: 'recent', + 인기순: 'popular', + 댓글순: 'comments', + }; + const searchParams = useSearchParams(); + const query = searchParams.get('sortBy'); + const router = useRouter(); + const handleChange = async (selectTitle: string) => { + if (!query) return; + try { + const res = await fetch(`${getApi}/cocktails`); + const json = await res.json(); + setData(json.data); + } catch { + console.error(); + console.log(selectTitle); + } + }; -function CocktailFilter({ cocktailsEA }: { cocktailsEA: string }) { return (

{cocktailsEA}개

- + { + const sortValue = sortMap[value as keyof typeof sortMap]; + handleChange(value); + router.push(`?sortBy=${sortValue}`); + }} + />
); } diff --git a/src/domains/recipe/components/main/CocktailList.tsx b/src/domains/recipe/components/main/CocktailList.tsx index 56c0ce7b..0b2f7e63 100644 --- a/src/domains/recipe/components/main/CocktailList.tsx +++ b/src/domains/recipe/components/main/CocktailList.tsx @@ -1,36 +1,35 @@ 'use client'; import { useRef } from 'react'; - import Link from 'next/link'; import { useIntersectionObserver } from '@/domains/shared/hook/useIntersectionObserver'; import { Cocktail } from '../../types/types'; import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; +import { useScrollRestore } from '@/domains/shared/hook/useMemoScroll'; interface Props { cocktails: Cocktail[]; RecipeFetch?: (cursor?: string | undefined) => Promise; hasNextPage: boolean; lastId: number | null; - onItemClick: () => void; } -function CocktailList({ cocktails, RecipeFetch, hasNextPage, lastId, onItemClick }: Props) { +function CocktailList({ cocktails, RecipeFetch, hasNextPage, lastId }: Props) { const cocktailRef = useRef(null); const onIntersect: IntersectionObserverCallback = ([entry]) => { if (!RecipeFetch) return; if (!lastId) return; - if (entry.isIntersecting) { + if (entry.isIntersecting && lastId > 1) { RecipeFetch(); } }; useIntersectionObserver(cocktailRef, onIntersect, hasNextPage); - - const handleClick = () => { - sessionStorage.setItem('listScrollY', String(window.scrollY)); - sessionStorage.setItem('saveUrl', String(location.href)); - }; - + const saveScroll = useScrollRestore({ + lastId, + fetchData: RecipeFetch!, + hasNextPage, + currentDataLength: cocktails.length, + }); return (
    {cocktails.map( - ({ cocktailImgUrl, cocktailId, cocktailName, cocktailNameKo, alcoholStrength }) => ( -
  • - + ({ + cocktailImgUrl, + cocktailId, + cocktailName, + cocktailNameKo, + alcoholStrength, + isFavorited, + }) => ( +
  • + ) )} -
    +
); } diff --git a/src/domains/recipe/components/main/Cocktails.tsx b/src/domains/recipe/components/main/Cocktails.tsx index c756d0d4..4d075d04 100644 --- a/src/domains/recipe/components/main/Cocktails.tsx +++ b/src/domains/recipe/components/main/Cocktails.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import CocktailFilter from './CocktailFilter'; import CocktailList from './CocktailList'; import { Cocktail } from '../../types/types'; -import { useMemoScroll } from '../../../shared/hook/useMemoScroll'; import Accordion from './Accordion'; import { RecipeFetch } from '../../api/RecipeFetch'; import CocktailSearchBar from './CocktailSearchBar'; @@ -12,22 +11,14 @@ import useSearchControl from '../../hook/useSearchControl'; import CocktailSearch from '../../api/CocktailSearch'; function Cocktails() { - const { - data, - setData, - lastId, - setLastId, - hasNextPage, - setHasNextPage, - handleItemClick, - shouldFetch, - } = useMemoScroll({ - storageKey: 'cocktails_scroll_state', - eventName: 'resetCocktailsScroll', - }); + const [data, setData] = useState([]); + const [lastId, setLastId] = useState(null); + const [hasNextPage, setHasNextPage] = useState(true); + const { inputValue, keyword, isSearching, onInputChange, noResults, setNoResults } = useSearchControl({ delay: 300, storageKey: 'cocktails_scoll_state' }); const { fetchData } = RecipeFetch({ setData, lastId, setLastId, hasNextPage, setHasNextPage }); + const { searchApi, setAlcoholBaseTypes, @@ -74,9 +65,9 @@ function Cocktails() { // 일반 fetch useEffect(() => { - if (!shouldFetch || isSearching) return; + if (isSearching) return; fetchData(); - }, [shouldFetch, isSearching]); + }, [isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); return (
@@ -89,7 +80,7 @@ function Cocktails() { - +
{isSearching && noResults ? ( @@ -100,7 +91,6 @@ function Cocktails() { RecipeFetch={isSearching ? undefined : fetchData} hasNextPage={isSearching ? false : hasNextPage} lastId={lastId} - onItemClick={handleItemClick} /> )}
diff --git a/src/domains/recipe/details/DetailMain.tsx b/src/domains/recipe/details/DetailMain.tsx index 65455b9e..de057e02 100644 --- a/src/domains/recipe/details/DetailMain.tsx +++ b/src/domains/recipe/details/DetailMain.tsx @@ -11,23 +11,45 @@ import { Suspense, useEffect, useState } from 'react'; import SkeletonDetail from '../skeleton/SkeletonDetail'; import RecipeComment from '../components/details/RecipeComment'; import { getApi } from '@/app/api/config/appConfig'; +import { useAuthStore } from '@/domains/shared/store/auth'; + +interface Kept { + cocktailId: number; + id: number; + keptAt: Date; +} function DetailMain({ id }: { id: number }) { + const user = useAuthStore(); const [cocktail, setCocktail] = useState(); + const [isKept, setIsKept] = useState(null); const fetchData = async () => { const res = await fetch(`${getApi}/cocktails/${id}`); const json = await res.json(); if (!res.ok) throw new Error('데이터 요청 실패'); setCocktail(json.data); + + if (!user) { + setIsKept(false); + return; + } else { + const keepRes = await fetch(`${getApi}/me/bar`, { + method: 'GET', + credentials: 'include', + }); + const keepjson = await keepRes.json(); + const keepIds = keepjson.data.map((a: Kept) => String(a.cocktailId)); + setIsKept(keepIds.includes(String(id))); + } }; useEffect(() => { fetchData(); - window.scrollTo(0, 0); }, []); useEffect(() => { + window.scrollTo(0, 0); return () => { // 레시피 페이지로 돌아가지 않는 경우 (헤더 탭 클릭 등) // 네비게이션 플래그를 제거하여 스크롤 복원 방지 @@ -42,6 +64,7 @@ function DetailMain({ id }: { id: number }) { if (!cocktail) return; const { + cocktailId, cocktailImgUrl, cocktailName, cocktailNameKo, @@ -56,7 +79,7 @@ function DetailMain({ id }: { id: number }) { }>

${cocktailNameKo} 상세정보

- +
@@ -91,7 +114,7 @@ function DetailMain({ id }: { id: number }) {
- +
diff --git a/src/domains/recipe/details/DetailsHeader.tsx b/src/domains/recipe/details/DetailsHeader.tsx index 9cc2c372..1e11bd91 100644 --- a/src/domains/recipe/details/DetailsHeader.tsx +++ b/src/domains/recipe/details/DetailsHeader.tsx @@ -12,14 +12,16 @@ interface Meta { url: string; } -function DetailsHeader({ id }: { id: number }) { +function DetailsHeader({ id, favor }: { id: number; favor: boolean | null }) { const [isShare, setIsShare] = useState(false); const [meta, setMeta] = useState(null); + const url = async () => { const res = await fetch(`${getApi}/cocktails/${id}/share`); const json = await res.json(); setMeta(json.data); }; + useEffect(() => { url(); }, []); @@ -38,7 +40,7 @@ function DetailsHeader({ id }: { id: number }) {
setIsShare(true)} /> - +
); diff --git a/src/domains/recipe/skeleton/SkeletonRecipe.tsx b/src/domains/recipe/skeleton/SkeletonRecipe.tsx index cb9fbd79..a2fa7716 100644 --- a/src/domains/recipe/skeleton/SkeletonRecipe.tsx +++ b/src/domains/recipe/skeleton/SkeletonRecipe.tsx @@ -17,18 +17,15 @@ function SkeletonRecipe() { {/* 리스트 자리 */}
    - {Array.from({ length: 8 }).map((_, i) => ( + {Array.from({ length: 20 }).map((_, i) => (
  • diff --git a/src/domains/recipe/types/types.ts b/src/domains/recipe/types/types.ts index 252e7d33..b30d4e91 100644 --- a/src/domains/recipe/types/types.ts +++ b/src/domains/recipe/types/types.ts @@ -4,6 +4,7 @@ export interface Cocktail { cocktailName: string; cocktailImgUrl: string; cocktailNameKo: string; + isFavorited: boolean; } export interface RecommendCocktail { diff --git a/src/domains/shared/components/cocktail-card/CocktailCard.tsx b/src/domains/shared/components/cocktail-card/CocktailCard.tsx index 3fadeb45..b113c4fa 100644 --- a/src/domains/shared/components/cocktail-card/CocktailCard.tsx +++ b/src/domains/shared/components/cocktail-card/CocktailCard.tsx @@ -17,6 +17,7 @@ interface Props { className?: string; textSize1?: string; textSize2?: string; + favor?: boolean; } function CocktailCard({ @@ -29,6 +30,7 @@ function CocktailCard({ textSize2, alcohol, id, + favor, }: Props) { const alcoholTitle = labelTitle(alcohol); @@ -44,7 +46,7 @@ function CocktailCard({ {keep && (
    {alcoholTitle &&
    - {id && } + {id && }
    )}
    diff --git a/src/domains/shared/components/comment/CommentHeader.tsx b/src/domains/shared/components/comment/CommentHeader.tsx index 08158041..fc00ada0 100644 --- a/src/domains/shared/components/comment/CommentHeader.tsx +++ b/src/domains/shared/components/comment/CommentHeader.tsx @@ -60,7 +60,7 @@ function CommentHeader({ intervalCall1000(async () => { const success = await createComment(newComment); if (!success) { - console.error('엔터키로 댓글 작성 실패'); + console.log('칵테일 페이지에서 댓글은 한개만 입력 가능합니다'); } }); } diff --git a/src/domains/shared/components/comment/CommentList.tsx b/src/domains/shared/components/comment/CommentList.tsx index 09604016..2a371f36 100644 --- a/src/domains/shared/components/comment/CommentList.tsx +++ b/src/domains/shared/components/comment/CommentList.tsx @@ -6,12 +6,14 @@ import { useInfiniteScrollObserver } from '@/shared/hook/useInfiniteScrollObserv import { useItemVirtualizer } from '@/domains/community/hook/useItemVirtualizer'; import { useCommentEnterAnimation } from '@/domains/community/hook/useCommentAnimation'; import { usePrevious } from 'react-use'; +import Link from 'next/link'; +import { getApi } from '@/app/api/config/appConfig'; type Props = { comments: CommentType[] | null; currentUserNickname?: string; - onUpdateComment: (commentId: number, postId: number, content: string) => Promise; - onDeleteComment: (commentId: number, postId: number) => void; + onUpdateComment?: (commentId: number, content: string) => Promise; + onDeleteComment?: (commentId: number) => void; onLoadMore?: (lastCommentId: number) => void; // ← 무한스크롤 콜백 isEnd?: boolean; isLoading: boolean; @@ -73,10 +75,97 @@ function CommentList({ const { commentId, content, userNickName, createdAt, postId } = comments[index]; const isEditing = editCommentId === commentId; const isMyComment = comments && currentUserNickname === userNickName; - const isLast = index === comments.length - 1; - return ( + return myPage ? ( + +
  • { + if (el) { + requestAnimationFrame(() => { + try { + rowVirtualizer.measureElement(el); + } catch (e) { + console.error('measureElement failed', e); + } + }); + if (index === 0) firstItemRef.current = el; + if (isLast) observeLastItem(el); + } + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + transform: `translateY(${start}px)`, + minHeight: '60px', // ← 최소 보장 + }} + > +
    + { + const updatedContent = editedContentMap[commentId]; + if (!updatedContent) return; + if (!onUpdateComment) return; + onUpdateComment(commentId, updatedContent).then(() => { + setEditCommentId(null); + setEditedContentMap((prev) => { + const next = { ...prev }; + delete next[commentId]; + return next; + }); + }); + }} + onDelete={() => { + if (!onDeleteComment) return; + onDeleteComment(commentId); + }} + onEdit={() => { + setEditCommentId(commentId); + setEditedContentMap((prev) => ({ + ...prev, + [commentId]: content, // 기존 내용 세팅 + })); + }} + onCancelEdit={() => { + setEditCommentId(null); + setEditedContentMap((prev) => { + const next = { ...prev }; + delete next[commentId]; + return next; + }); + }} + /> +
    + {isEditing ? ( + + setEditedContentMap((prev) => ({ + ...prev, + [commentId]: e.target.value, + })) + } + /> + ) : ( +
    +

    {content}

    +
    + )} +
    +
    +
  • + + ) : (
  • { const updatedContent = editedContentMap[commentId]; if (!updatedContent) return; - onUpdateComment(commentId, postId, updatedContent).then(() => { + if (!onUpdateComment) return; + onUpdateComment(commentId, updatedContent).then(() => { setEditCommentId(null); setEditedContentMap((prev) => { const next = { ...prev }; @@ -122,7 +212,10 @@ function CommentList({ }); }); }} - onDelete={() => onDeleteComment(commentId, postId)} + onDelete={() => { + if (!onDeleteComment) return; + onDeleteComment(commentId); + }} onEdit={() => { setEditCommentId(commentId); setEditedContentMap((prev) => ({ diff --git a/src/domains/shared/components/keep/Keep.tsx b/src/domains/shared/components/keep/Keep.tsx index bc306688..aa3b8811 100644 --- a/src/domains/shared/components/keep/Keep.tsx +++ b/src/domains/shared/components/keep/Keep.tsx @@ -2,27 +2,38 @@ import KeepIcon from '@/shared/assets/icons/keep_36.svg'; import KeepIconActive from '@/shared/assets/icons/keep_active_36.svg'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { deleteKeep, postKeep } from '../../api/keep/keep'; import { useToast } from '@/shared/hook/useToast'; +import { useAuthStore } from '../../store/auth'; interface Props { className?: string; cocktailId?: number; + favor?: boolean | null; } // ID는 커뮤니티 공유할때 id 타입보고 옵셔널 체크 풀어주세요! // 만약 타입 안맞는다면 그냥 두셔도 됩니다. -function Keep({ className, cocktailId }: Props) { - const { toastSuccess } = useToast(); - const [isClick, setIsClick] = useState(false); +function Keep({ className, cocktailId, favor }: Props) { + const { user } = useAuthStore(); + const { toastInfo, toastSuccess } = useToast(); + const [isClick, setIsClick] = useState(favor); + + useEffect(() => { + setIsClick(favor); + }, [favor]); const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setIsClick(!isClick); + if (!user) { + toastInfo('로그인 후 이용 가능합니다.'); + return; + } + setIsClick(!isClick); try { if (!cocktailId) return; if (!isClick) { diff --git a/src/domains/shared/hook/useIntersectionObserver.ts b/src/domains/shared/hook/useIntersectionObserver.ts index 6566b9f6..85ddc67b 100644 --- a/src/domains/shared/hook/useIntersectionObserver.ts +++ b/src/domains/shared/hook/useIntersectionObserver.ts @@ -11,7 +11,7 @@ export const useIntersectionObserver = ( if (targetRef && targetRef.current) { observer.current = new IntersectionObserver(onIntersect, { root: null, - rootMargin: '0px', + rootMargin: '200px', threshold: 1.0, }); if (!hasNextPage) { diff --git a/src/domains/shared/hook/useMemoScroll.ts b/src/domains/shared/hook/useMemoScroll.ts index 5be220e7..87bd13fd 100644 --- a/src/domains/shared/hook/useMemoScroll.ts +++ b/src/domains/shared/hook/useMemoScroll.ts @@ -1,230 +1,156 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; - -interface UseScrollRestorationProps { - storageKey: string; - eventName?: string; -} - -interface ScrollState { - scrollY: number; - data: T[]; - lastId: number | null; - hasNextPage: boolean; - timestamp: number; +// useScrollRestore.ts +import { usePathname } from 'next/navigation'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +interface UseScrollRestoreProps { + lastId: number | null; // 현재까지 로드한 "최소 id"(내림차순에서 커서) + fetchData: (cursor?: string) => Promise; + currentDataLength: number; + hasNextPage?: boolean; // 선택: 있으면 조기 종료에 사용 } -// 뒤로가기시 스크롤위치 기억 함수 - -export function useMemoScroll({ - storageKey, - eventName = 'resetScroll', -}: UseScrollRestorationProps) { - // 뒤로가기를 통해 목록 복원을 저장해주는 플래그 - const NAVIGATION_FLAG_KEY = `${storageKey}_nav_flag`; +type SavedShape = { targetId: number | null; scrollY: number }; - // 실제 렌더링 되는 데이터 - const [data, setData] = useState([]); - const [lastId, setLastId] = useState(null); - const [hasNextPage, setHasNextPage] = useState(true); - const [shouldFetch, setShouldFetch] = useState(false); +export function useScrollRestore({ + lastId, + fetchData, + currentDataLength, + hasNextPage, +}: UseScrollRestoreProps) { + const pathname = usePathname(); + const KEY = `scroll-${pathname}`; - // 스크롤 복원중일 때 값이 바뀜 const isRestoringRef = useRef(false); - // 스크롤 복원 후 값 바뀜 - const scrollRestoredRef = useRef(false); - // 컴포넌트 마운트시 값 바뀜 - const hasMountedRef = useRef(false); - - // 스크롤 위치와 데이터 저장 - const saveScrollState = useCallback(() => { - // 복원 중일때와 일정 스크롤 이상 안내려오면 저장 안함 - if (isRestoringRef.current || window.scrollY < 10) return; - - const scrollState: ScrollState = { - scrollY: window.scrollY, - data: data, - lastId: lastId, - hasNextPage: hasNextPage, - timestamp: Date.now(), - }; + const hasRestoredRef = useRef(false); + const lastIdRef = useRef(lastId); + const lenRef = useRef(currentDataLength); - sessionStorage.setItem(storageKey, JSON.stringify(scrollState)); - }, [data, lastId, hasNextPage, storageKey]); - - // 저장된 상태 복원 - const restoreScrollState = useCallback(() => { - const saved = sessionStorage.getItem(storageKey); - const navFlag = sessionStorage.getItem(NAVIGATION_FLAG_KEY); + useEffect(() => { + lastIdRef.current = lastId; + }, [lastId]); + useEffect(() => { + lenRef.current = currentDataLength; + }, [currentDataLength]); + + // 브라우저 기본 복원 비활성화 + useLayoutEffect(() => { + if ('scrollRestoration' in history) { + try { + history.scrollRestoration = 'manual'; + } catch {} + } + }, []); + + const jumpOnce = useCallback( + (y: number) => { + const el = document.scrollingElement || document.documentElement; + const enough = () => document.body.scrollHeight >= y + window.innerHeight; + let done = false; + + const finish = () => { + if (done) return; + done = true; + el.scrollTo({ top: y, behavior: 'auto' }); + isRestoringRef.current = false; + hasRestoredRef.current = true; + sessionStorage.removeItem(KEY); + }; + + if (enough()) { + requestAnimationFrame(finish); + return; + } - if (!saved) return false; + const ro = new ResizeObserver(() => { + if (enough()) { + ro.disconnect(); + finish(); + } + }); + ro.observe(document.body); + window.addEventListener( + 'load', + () => { + if (enough()) finish(); + }, + { once: true } + ); + setTimeout(() => finish(), 1000); + }, + [KEY] + ); + + // 복원 + useEffect(() => { + if (hasRestoredRef.current) return; - // 네비게이션 플래그가 없으면 복원하지 않음 (새로 진입한 경우) - if (navFlag !== 'back') { - sessionStorage.removeItem(storageKey); - return false; + const raw = sessionStorage.getItem(KEY); + if (!raw) { + hasRestoredRef.current = true; + return; } - // 플래그 사용 후 제거 - sessionStorage.removeItem(NAVIGATION_FLAG_KEY); - + let saved: SavedShape | null = null; try { - // 데이터 복원 - const parsed = JSON.parse(saved); - - const { - scrollY, - data: savedData, - lastId: savedLastId, - hasNextPage: savedHasNextPage, - timestamp, - }: ScrollState = parsed; - - // 세션이 30분 지나가면 세션 삭제 - const isRecent = Date.now() - timestamp < 30 * 60 * 1000; - - if (isRecent && savedData.length > 0 && scrollY > 10) { - // 조건 충족 시 스크롤 데이터 복원 - - isRestoringRef.current = true; - - setData(savedData); - setLastId(savedLastId); - setHasNextPage(savedHasNextPage); - - const restoreScroll = () => { - // 스크롤 복원 시도 로직 - window.scrollTo({ - top: scrollY, - behavior: 'instant', - }); - - setTimeout(() => { - // 무한 스크롤시 데이터에따라 한번에 스크롤 복원 안되는 현상 발생 => 재시도 로직 - const currentScroll = window.scrollY; - const diff = Math.abs(currentScroll - scrollY); - - if (diff > 5) { - // 스크롤 재 조정이 필요한 경우 실행 - window.scrollTo({ - top: scrollY, - behavior: 'instant', - }); - } - - setTimeout(() => { - // 복원 완료 ref초기화 - isRestoringRef.current = false; - scrollRestoredRef.current = true; - }, 300); - }, 100); - }; - - // 재 조정시 애니메이션 매끄럽게 처리 - setTimeout(restoreScroll, 0); - requestAnimationFrame(() => { - setTimeout(restoreScroll, 50); - }); - - return true; - } - } catch (err) { - console.error(err); - sessionStorage.removeItem(storageKey); - return false; + saved = JSON.parse(raw) as SavedShape; + } catch { + sessionStorage.removeItem(KEY); + return; } - }, [storageKey, NAVIGATION_FLAG_KEY]); - - // 스크롤 리셋 - const resetScroll = useCallback(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - sessionStorage.removeItem(storageKey); - sessionStorage.removeItem(NAVIGATION_FLAG_KEY); - }, [storageKey, NAVIGATION_FLAG_KEY]); - - // 아이템 클릭 시 호출할 함수 (네비게이션 플래그 설정) - const handleItemClick = useCallback(() => { - if (!isRestoringRef.current && window.scrollY > 10) { - saveScrollState(); - - // 뒤로가기임을 표시하는 플래그 설정 - sessionStorage.setItem(NAVIGATION_FLAG_KEY, 'back'); + if (!saved) { + sessionStorage.removeItem(KEY); + return; } - }, [saveScrollState, NAVIGATION_FLAG_KEY]); - // 컴포넌트 마운트 시 복원 시도 - useEffect(() => { - if (hasMountedRef.current) return; - hasMountedRef.current = true; + const { targetId, scrollY } = saved; + isRestoringRef.current = true; - const restored = restoreScrollState(); + const MAX_FETCH = 50; - if (!restored) { - setShouldFetch(true); - } - }, [restoreScrollState]); + const restore = async () => { + let tries = 0; + let lastProgressLen = lenRef.current; + let lastProgressId = lastIdRef.current; - // 헤더에서 같은 페이지 클릭 시 이벤트 리스너 - useEffect(() => { - const handleResetScroll = () => { - resetScroll(); - }; + // 내림차순 전제: + // 더 불러올수록 현재 최소 id(=lastIdRef.current)가 "작아진다" + // 목표 지점 도달 조건: currentMinId <= targetId 또는 targetId==null + while ( + targetId != null && + (lastIdRef.current == null || (lastIdRef.current as number) > targetId) + ) { + if (hasNextPage === false) break; // 더 없음 + if (tries++ >= MAX_FETCH) break; // 안전망 - window.addEventListener(eventName, handleResetScroll); + await fetchData(); - return () => { - window.removeEventListener(eventName, handleResetScroll); - }; - }, [eventName, resetScroll]); + // 진행 없음(길이와 lastId 모두 동일) → 중단 + const noLenChange = lenRef.current === lastProgressLen; + const noIdChange = lastIdRef.current === lastProgressId; + if (noLenChange && noIdChange) break; - // 언마운트 시 정리 (복원되지 않은 데이터 정리) - useEffect(() => { - return () => { - // 네비게이션 플래그가 없으면 데이터도 삭제 - const navFlag = sessionStorage.getItem(NAVIGATION_FLAG_KEY); - if (!navFlag) { - sessionStorage.removeItem(storageKey); - } - }; - }, [storageKey, NAVIGATION_FLAG_KEY]); + lastProgressLen = lenRef.current; + lastProgressId = lastIdRef.current; - // 스크롤 이벤트 리스너 - useEffect(() => { - if (data.length === 0) return; - - const handleScroll = () => { - if (isRestoringRef.current) return; - - if (scrollRestoredRef.current) { - scrollRestoredRef.current = false; + // 다음 렌더로 넘겨 레이아웃 안정화 + await new Promise((r) => setTimeout(r, 0)); } - saveScrollState(); + requestAnimationFrame(() => jumpOnce(scrollY)); }; - // 디바운스 유틸을 이벤트마다 새로 생성시 timer초기화 => 로컬timeout사용 - let timeoutId: NodeJS.Timeout; - const debouncedHandleScroll = () => { - clearTimeout(timeoutId); - timeoutId = setTimeout(handleScroll, 100); - }; + restore(); + }, [KEY, fetchData, hasNextPage, jumpOnce]); - window.addEventListener('scroll', debouncedHandleScroll, { passive: true }); - - return () => { - clearTimeout(timeoutId); - window.removeEventListener('scroll', debouncedHandleScroll); + // 저장 + const saveScroll = useCallback(() => { + const payload: SavedShape = { + targetId: lastIdRef.current, + scrollY: window.scrollY, }; - }, [data, saveScrollState]); - - return { - data, - setData, - lastId, - setLastId, - hasNextPage, - setHasNextPage, - handleItemClick, - saveScrollState, - shouldFetch, - }; + sessionStorage.setItem(KEY, JSON.stringify(payload)); + sessionStorage.setItem('saveUrl', location.href); + }, [KEY]); + + return saveScroll; } diff --git a/src/shared/components/tool-tip/ToolTip.tsx b/src/shared/components/tool-tip/ToolTip.tsx index 12e885c9..9a703a20 100644 --- a/src/shared/components/tool-tip/ToolTip.tsx +++ b/src/shared/components/tool-tip/ToolTip.tsx @@ -89,7 +89,7 @@ function ToolTip({ position, message, viewPoint = 'web', children, ref }: Props) aria-expanded={isVisible} ref={ref} onMouseLeave={() => setIsVisible(false)} - onClick={handleClick} + onMouseEnter={handleClick} > {children} {isVisible && ( diff --git a/src/shared/styles/global.css b/src/shared/styles/global.css index 5bc37f34..936c9ee2 100644 --- a/src/shared/styles/global.css +++ b/src/shared/styles/global.css @@ -14,3 +14,7 @@ color: #fff; background-color: var(--color-primary); } + +html { + scroll-behavior: auto; /* smooth가 아닌 auto로 */ +} \ No newline at end of file