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