1- import { IconButton , useToken } from "@chakra-ui/react" ;
1+ import { IconButton } from "@chakra-ui/react" ;
22import { partition } from "es-toolkit/array" ;
3- import { motion , useReducedMotion } from "framer-motion" ;
4- import React , { useCallback , useState } from "react" ;
3+ import { motion } from "framer-motion" ;
4+ import React from "react" ;
55import { IoIosStarOutline } from "react-icons/io" ;
66import { IoStar } from "react-icons/io5" ;
77
88import { ANIMATION_DURATIONS , EASING } from "~/components/animations/micro-interactions" ;
99
10- const MotionIconButton = motion . create ( IconButton ) ;
11-
12- export type FavoriteValue = string | number ;
13-
14- export const getFavoritesFromStorage = < T extends FavoriteValue > ( key : string ) : T [ ] => {
15- try {
16- const storedItems = localStorage . getItem ( key ) ;
17- return storedItems ? JSON . parse ( storedItems ) : [ ] ;
18- } catch ( error ) {
19- console . error ( `Error retrieving favorites from storage (${ key } ):` , error ) ;
20- return [ ] ;
21- }
22- } ;
10+ import {
11+ FavoriteValue ,
12+ getFavoritesFromStorage ,
13+ saveFavoritesToStorage ,
14+ useFavoriteIcon ,
15+ } from "./hooks/use-favorite-icon" ;
2316
24- export const saveFavoritesToStorage = < T extends FavoriteValue > (
25- key : string ,
26- favorites : T [ ]
27- ) : void => {
28- try {
29- localStorage . setItem ( key , JSON . stringify ( favorites ) ) ;
30- } catch ( error ) {
31- console . error ( `Error saving favorites to storage (${ key } ):` , error ) ;
32- }
33- } ;
34-
35- export const toggleFavorite = < T extends FavoriteValue > (
36- value : T ,
37- currentFavorites : T [ ] ,
38- storageKey ?: string
39- ) : T [ ] => {
40- const newFavorites = currentFavorites . includes ( value )
41- ? currentFavorites . filter ( ( item ) => item !== value )
42- : [ ...currentFavorites , value ] ;
43-
44- if ( storageKey ) {
45- saveFavoritesToStorage ( storageKey , newFavorites ) ;
46- }
17+ const MotionIconButton = motion . create ( IconButton ) ;
4718
48- return newFavorites ;
49- } ;
19+ export type { FavoriteValue } ;
20+ export { getFavoritesFromStorage , saveFavoritesToStorage } ;
5021
5122type FavoriteIconProps = {
5223 externalFavorites ?: FavoriteValue [ ] ;
@@ -55,135 +26,51 @@ type FavoriteIconProps = {
5526 storageKey : string ;
5627} ;
5728
58- export const FavoriteIcon : React . FC < FavoriteIconProps > = ( {
59- externalFavorites,
60- id,
61- onChange,
62- storageKey,
63- } ) => {
64- const [ internalFavorites , setInternalFavorites ] = useState < FavoriteValue [ ] > ( ( ) =>
65- getFavoritesFromStorage ( storageKey )
66- ) ;
67- const [ isAnimating , setIsAnimating ] = useState ( false ) ;
68- const [ isHovered , setIsHovered ] = useState ( false ) ;
69- const shouldReduceMotion = useReducedMotion ( ) ;
70- const [ gold500 ] = useToken ( "colors" , "gold.500" ) ;
71-
72- const favorites = externalFavorites !== undefined ? externalFavorites : internalFavorites ;
73-
74- const handleToggle = useCallback (
75- ( e : React . MouseEvent ) => {
76- e . stopPropagation ( ) ;
77-
78- setIsHovered ( false ) ;
79- const element = e . currentTarget as HTMLElement ;
80- element . blur ( ) ;
81-
82- const mouseLeaveEvent = new MouseEvent ( "mouseleave" , {
83- bubbles : true ,
84- cancelable : true ,
85- view : window ,
86- } ) ;
87- element . dispatchEvent ( mouseLeaveEvent ) ;
88-
89- const wasNotFavorite = ! favorites . includes ( id ) ;
90- const newFavorites = toggleFavorite ( id , favorites , storageKey ) ;
91-
92- if ( wasNotFavorite && ! shouldReduceMotion ) {
93- setIsAnimating ( true ) ;
94- setTimeout ( ( ) => {
95- setIsAnimating ( false ) ;
96- setIsHovered ( false ) ;
97- } , ANIMATION_DURATIONS . slow * 1000 ) ;
98- }
99-
100- if ( externalFavorites === undefined ) {
101- setInternalFavorites ( newFavorites ) ;
102- }
103-
104- if ( onChange ) {
105- onChange ( newFavorites ) ;
106- }
107- } ,
108- [ id , favorites , storageKey , onChange , externalFavorites , shouldReduceMotion ]
109- ) ;
110-
111- const isFavorite = favorites . includes ( id ) ;
29+ export const FavoriteIcon : React . FC < FavoriteIconProps > = ( props ) => {
30+ const {
31+ buttonAnimation,
32+ gold500,
33+ gray400,
34+ handleMouseEnter,
35+ handleMouseLeave,
36+ handleToggle,
37+ isAnimating,
38+ isFavorite,
39+ shouldReduceMotion,
40+ } = useFavoriteIcon ( props ) ;
11241
11342 return (
11443 < MotionIconButton
115- animate = {
116- isAnimating && ! shouldReduceMotion
117- ? {
118- boxShadow : [
119- "0 0 0px rgba(255, 215, 0, 0)" ,
120- "0 0 25px rgba(255, 215, 0, 0.8)" ,
121- "0 0 10px rgba(255, 215, 0, 0.3)" ,
122- "0 0 0px rgba(255, 215, 0, 0)" ,
123- ] ,
124- rotate : [ 0 , 10 , - 10 , 0 ] ,
125- scale : [ 1 , 1.2 , 1 ] ,
126- }
127- : isHovered && ! shouldReduceMotion && ! isAnimating
128- ? {
129- boxShadow : isFavorite
130- ? "0 0 15px rgba(255, 215, 0, 0.5)"
131- : "0 0 10px rgba(128, 128, 128, 0.3)" ,
132- scale : 1.05 ,
133- }
134- : {
135- boxShadow : "0 0 0px rgba(255, 215, 0, 0)" ,
136- scale : 1 ,
137- }
138- }
44+ animate = { buttonAnimation }
13945 aria-label = { isFavorite ? "즐겨찾기 해제" : "즐겨찾기 추가" }
14046 aria-pressed = { isFavorite }
14147 minH = { { base : "44px" , md : "32px" } }
14248 minW = { { base : "44px" , md : "32px" } }
14349 onClick = { handleToggle }
144- onMouseEnter = { ( ) => setIsHovered ( true ) }
145- onMouseLeave = { ( ) => setIsHovered ( false ) }
50+ onMouseEnter = { handleMouseEnter }
51+ onMouseLeave = { handleMouseLeave }
14652 size = { { base : "md" , md : "sm" } }
147- style = { {
148- backgroundColor : isHovered
149- ? process . env . NODE_ENV === "dark"
150- ? "rgba(255, 255, 255, 0.1)"
151- : "rgba(0, 0, 0, 0.1)"
152- : undefined ,
153- } }
154- transition = { {
155- duration : ANIMATION_DURATIONS . normal ,
156- ease : EASING . easeOut ,
157- } }
53+ transition = { { duration : ANIMATION_DURATIONS . normal , ease : EASING . easeOut } }
15854 variant = "ghost"
15955 >
16056 { isFavorite ? (
16157 < motion . div
16258 animate = {
16359 ! shouldReduceMotion
164- ? {
165- rotate : isAnimating ? [ 0 , 360 ] : 0 ,
166- scale : isAnimating ? [ 1 , 1.3 , 1 ] : 1 ,
167- }
60+ ? { rotate : isAnimating ? [ 0 , 360 ] : 0 , scale : isAnimating ? [ 1 , 1.3 , 1 ] : 1 }
16861 : { }
16962 }
17063 initial = { false }
171- transition = { {
172- duration : ANIMATION_DURATIONS . slow ,
173- ease : EASING . spring ,
174- } }
64+ transition = { { duration : ANIMATION_DURATIONS . slow , ease : EASING . spring } }
17565 >
17666 < IoStar color = { gold500 } size = { 20 } />
17767 </ motion . div >
17868 ) : (
17969 < motion . div
180- transition = { {
181- duration : ANIMATION_DURATIONS . fast ,
182- ease : EASING . easeOut ,
183- } }
70+ transition = { { duration : ANIMATION_DURATIONS . fast , ease : EASING . easeOut } }
18471 whileHover = { ! shouldReduceMotion ? { scale : 1.1 } : undefined }
18572 >
186- < IoIosStarOutline size = { 20 } style = { { color : "#A1A1AA" } } />
73+ < IoIosStarOutline color = { gray400 } size = { 20 } />
18774 </ motion . div >
18875 ) }
18976 </ MotionIconButton >
0 commit comments