diff --git a/apps/web/package.json b/apps/web/package.json index ad2b832..e1bad01 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.33.0", "gsap": "^3.14.2", "immer": "^10.1.1", "lottie-react": "^2.4.1", diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index ebce66e..acfccf2 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -1,38 +1,70 @@ 'use client'; +import { motion } from 'framer-motion'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; +import useFooterAnimateStore, { FooterKey } from '@/stores/useFooterAnimateStore'; import { cn } from '@/utils/cn'; -const navigation = [ - { name: '최신 곡', href: '/recent' }, +interface Navigation { + name: string; + href: string; + key: FooterKey; +} + +const navigation: Navigation[] = [ + { name: '최신 곡', href: '/recent', key: 'RECENT' }, - { name: '부를 곡', href: '/tosing' }, - { name: '검색', href: '/' }, + { name: '부를 곡', href: '/tosing', key: 'TOSING' }, + { name: '검색', href: '/', key: 'SEARCH' }, - { name: '인기곡', href: '/popular' }, - { name: '정보', href: '/info' }, + { name: '인기곡', href: '/popular', key: 'POPULAR' }, + { name: '정보', href: '/info', key: 'INFO' }, ]; export default function Footer() { const pathname = usePathname(); + const { footerAnimateKey } = useFooterAnimateStore(); const navPath = pathname.split('/')[1]; return ( diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx index 30e0557..47a1557 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -11,6 +11,7 @@ import { Slider } from '@/components/ui/slider'; import { useSongThumbMutation } from '@/queries/songThumbQuery'; import { useUserQuery } from '@/queries/userQuery'; import { usePatchSetPointMutation } from '@/queries/userQuery'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; import FallingIcons from './FallingIcons'; @@ -29,10 +30,14 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) const { mutate: patchSongThumb, isPending: isPendingSongThumb } = useSongThumbMutation(); const { mutate: patchSetPoint, isPending: isPendingSetPoint } = usePatchSetPointMutation(); + const { setFooterAnimateKey } = useFooterAnimateStore(); + const handleClickThumb = () => { patchSongThumb({ songId, point: value[0] }); patchSetPoint({ point: point - value[0] }); + setFooterAnimateKey('POPULAR'); + handleClose(); }; diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 3682606..f33011c 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -9,6 +9,7 @@ import { useToggleToSingMutation, } from '@/queries/searchSongQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { Method } from '@/types/common'; @@ -46,6 +47,7 @@ export default function useSearchSong() { isError, } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); + const { setFooterAnimateKey } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); @@ -75,6 +77,7 @@ export default function useSearchSong() { if (!isAuthenticated) { if (method === 'POST') { addGuestToSingSong(song); + setFooterAnimateKey('TOSING'); } else { removeGuestToSingSong(song.id); } @@ -85,6 +88,10 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + if (method === 'POST') { + setFooterAnimateKey('TOSING'); + } toggleToSing({ songId: song.id, method }); }; @@ -98,6 +105,10 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + if (method === 'POST') { + setFooterAnimateKey('INFO'); + } toggleLike({ songId, method }); }; @@ -116,6 +127,8 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + setFooterAnimateKey('INFO'); postSong({ songId, folderName, query, searchType }); }; @@ -125,6 +138,7 @@ export default function useSearchSong() { return; } + setFooterAnimateKey('INFO'); moveSong({ songIdArray: [songId], folderId }); }; diff --git a/apps/web/src/stores/useFooterAnimateStore.ts b/apps/web/src/stores/useFooterAnimateStore.ts new file mode 100644 index 0000000..060208d --- /dev/null +++ b/apps/web/src/stores/useFooterAnimateStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +export type FooterKey = 'SEARCH' | 'RECENT' | 'TOSING' | 'POPULAR' | 'INFO' | null; + +interface FooterStore { + footerAnimateKey: FooterKey; + timeoutId: ReturnType | null; + setFooterAnimateKey: (key: FooterKey) => void; +} + +const initialState = { + footerAnimateKey: null, + timeoutId: null, +}; + +const useFooterAnimateStore = create((set, get) => ({ + ...initialState, + + setFooterAnimateKey: key => { + const { timeoutId } = get(); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + set({ footerAnimateKey: key }); + + const newTimeoutId = setTimeout(() => { + set({ footerAnimateKey: null, timeoutId: null }); + }, 300); + + set({ timeoutId: newTimeoutId }); + }, +})); + +export default useFooterAnimateStore; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe2df0c..37d1aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + framer-motion: + specifier: ^12.33.0 + version: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -4564,8 +4567,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.29.0: - resolution: {integrity: sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==} + framer-motion@12.33.0: + resolution: {integrity: sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -5836,11 +5839,11 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - motion-dom@12.29.0: - resolution: {integrity: sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==} + motion-dom@12.33.0: + resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} - motion-utils@12.27.2: - resolution: {integrity: sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==} + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} motion@12.29.0: resolution: {integrity: sha512-rjB5CP2N9S2ESAyEFnAFMgTec6X8yvfxLNcz8n12gPq3M48R7ZbBeVYkDOTj8SPMwfvGIFI801SiPSr1+HCr9g==} @@ -12824,10 +12827,10 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.29.0 - motion-utils: 12.27.2 + motion-dom: 12.33.0 + motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -14441,15 +14444,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - motion-dom@12.29.0: + motion-dom@12.33.0: dependencies: - motion-utils: 12.27.2 + motion-utils: 12.29.2 - motion-utils@12.27.2: {} + motion-utils@12.29.2: {} motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3