Skip to content

Commit be3fc21

Browse files
committed
refactor(frontend): FavoriteIcon 컴포넌트에서 로직을 커스텀 훅으로 분리
FavoriteIcon 컴포넌트의 상태 관리, 이벤트 핸들링, 애니메이션 로직을 useFavoriteIcon 훅으로 추출하여 컴포넌트를 순수 렌더링 역할로 단순화 - 컴포넌트 223줄 → 108줄로 52% 감소 - dispatchEvent 대신 직접 상태 제어로 개선 - 순환 참조 방지를 위해 FavoriteValue 타입을 훅에서 정의 fix #270
1 parent ef5bb56 commit be3fc21

File tree

4 files changed

+218
-147
lines changed

4 files changed

+218
-147
lines changed

src/frontend/src/components/table/favorite-control.tsx

Lines changed: 32 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,23 @@
1-
import { IconButton, useToken } from "@chakra-ui/react";
1+
import { IconButton } from "@chakra-ui/react";
22
import { 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";
55
import { IoIosStarOutline } from "react-icons/io";
66
import { IoStar } from "react-icons/io5";
77

88
import { 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

5122
type 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>
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
export { getValueFromPath, useFavoriteRows } from "./use-favorite-rows";
1+
export type { FavoriteValue } from "./use-favorite-icon";
2+
export { useFavoriteIcon } from "./use-favorite-icon";
3+
export { useFavoriteRows } from "./use-favorite-rows";
24
export { useTableSort } from "./use-table-sort";

0 commit comments

Comments
 (0)