refactor: useScheduleSearch 훅 분리 - 검색 로직 캡슐화
- useScheduleSearch.js 생성 (217줄) - 검색어 자동완성 API 호출 (debounce) - 무한 스크롤 검색 결과 (useInfiniteQuery) - 키보드 네비게이션 핸들러 - Schedules.jsx: 1139줄 → 1009줄 (130줄 감소) - 검색 관련 상태/로직을 훅으로 이동 - 컴포넌트 복잡도 감소 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
81a2112b59
commit
08d704da5c
3 changed files with 251 additions and 161 deletions
|
|
@ -1,2 +1,5 @@
|
|||
// 관리자 인증
|
||||
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
|
||||
|
||||
// 일정 검색
|
||||
export { useScheduleSearch } from './useScheduleSearch';
|
||||
|
|
|
|||
217
frontend-temp/src/hooks/pc/admin/useScheduleSearch.js
Normal file
217
frontend-temp/src/hooks/pc/admin/useScheduleSearch.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* 일정 검색 관련 커스텀 훅
|
||||
* - 검색어 자동완성
|
||||
* - 무한 스크롤 검색 결과
|
||||
*/
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import useScheduleStore from '@/stores/useScheduleStore';
|
||||
import * as schedulesApi from '@/api/admin/schedules';
|
||||
|
||||
const SEARCH_LIMIT = 20;
|
||||
|
||||
export function useScheduleSearch() {
|
||||
// Zustand 스토어에서 검색 상태 가져오기
|
||||
const {
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
isSearchMode,
|
||||
setIsSearchMode,
|
||||
} = useScheduleStore();
|
||||
|
||||
// 검색 추천 관련 상태
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
const { ref: loadMoreRef, inView } = useInView({
|
||||
threshold: 0,
|
||||
rootMargin: '100px',
|
||||
});
|
||||
|
||||
// useInfiniteQuery for search
|
||||
const {
|
||||
data: searchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: searchLoading,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['adminScheduleSearch', searchTerm],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.hasMore) {
|
||||
return lastPage.offset + lastPage.schedules.length;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
enabled: !!searchTerm && isSearchMode,
|
||||
});
|
||||
|
||||
// Flatten search results
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchData?.pages) return [];
|
||||
return searchData.pages.flatMap((page) => page.schedules);
|
||||
}, [searchData]);
|
||||
|
||||
// Auto fetch next page when scrolled to bottom
|
||||
const prevInViewRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
|
||||
fetchNextPage();
|
||||
}
|
||||
prevInViewRef.current = inView;
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
|
||||
|
||||
// 검색어 자동완성 API 호출 (debounce 적용)
|
||||
useEffect(() => {
|
||||
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data.suggestions || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추천 검색어 API 오류:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [originalSearchQuery]);
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = useCallback((query) => {
|
||||
const trimmedQuery = query?.trim() || '';
|
||||
if (trimmedQuery) {
|
||||
setSearchTerm(trimmedQuery);
|
||||
setIsSearchMode(true);
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}, [setSearchTerm, setIsSearchMode]);
|
||||
|
||||
// 검색 모드 종료
|
||||
const exitSearchMode = useCallback(() => {
|
||||
setIsSearchMode(false);
|
||||
setSearchTerm('');
|
||||
setSearchInput('');
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
setOriginalSearchQuery('');
|
||||
}, [setIsSearchMode, setSearchTerm, setSearchInput]);
|
||||
|
||||
// 검색어 입력 핸들러
|
||||
const handleSearchInputChange = useCallback((value) => {
|
||||
setSearchInput(value);
|
||||
setOriginalSearchQuery(value);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
if (value.trim()) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [setSearchInput]);
|
||||
|
||||
// 추천 검색어 선택
|
||||
const handleSuggestionSelect = useCallback((suggestion) => {
|
||||
setSearchInput(suggestion);
|
||||
handleSearch(suggestion);
|
||||
}, [setSearchInput, handleSearch]);
|
||||
|
||||
// 키보드 네비게이션 핸들러
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (!showSuggestions || suggestions.length === 0) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch(searchInput);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex((prev) => {
|
||||
const next = prev < suggestions.length - 1 ? prev + 1 : prev;
|
||||
if (next >= 0 && suggestions[next]) {
|
||||
setSearchInput(suggestions[next]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex((prev) => {
|
||||
const next = prev > 0 ? prev - 1 : -1;
|
||||
if (next === -1) {
|
||||
setSearchInput(originalSearchQuery);
|
||||
} else if (suggestions[next]) {
|
||||
setSearchInput(suggestions[next]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
|
||||
handleSuggestionSelect(suggestions[selectedSuggestionIndex]);
|
||||
} else {
|
||||
handleSearch(searchInput);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
break;
|
||||
}
|
||||
}, [showSuggestions, suggestions, selectedSuggestionIndex, searchInput, originalSearchQuery, handleSearch, handleSuggestionSelect, setSearchInput]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
searchInput,
|
||||
searchTerm,
|
||||
isSearchMode,
|
||||
setIsSearchMode,
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
selectedSuggestionIndex,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
|
||||
// 핸들러
|
||||
handleSearch,
|
||||
exitSearchMode,
|
||||
handleSearchInputChange,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
|
||||
// refs
|
||||
loadMoreRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useScheduleSearch;
|
||||
|
|
@ -14,15 +14,14 @@ import {
|
|||
ArrowLeft,
|
||||
Book,
|
||||
} from 'lucide-react';
|
||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import { Toast } from '@/components/common';
|
||||
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||
import { ScheduleItem, getEditPath } from '@/components/pc/admin/schedule';
|
||||
import useScheduleStore from '@/stores/useScheduleStore';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useAdminAuth, useScheduleSearch } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import { getTodayKST, formatDate } from '@/utils';
|
||||
import { getCategoryId, getScheduleDate } from '@/utils/schedule';
|
||||
|
|
@ -33,14 +32,8 @@ function Schedules() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Zustand 스토어에서 상태 가져오기
|
||||
// Zustand 스토어에서 상태 가져오기 (검색 제외)
|
||||
const {
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
isSearchMode,
|
||||
setIsSearchMode,
|
||||
selectedCategories,
|
||||
setSelectedCategories,
|
||||
selectedDate,
|
||||
|
|
@ -53,95 +46,36 @@ function Schedules() {
|
|||
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 검색 관련 (커스텀 훅)
|
||||
const {
|
||||
searchInput,
|
||||
searchTerm,
|
||||
isSearchMode,
|
||||
setIsSearchMode,
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
selectedSuggestionIndex,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
handleSearch,
|
||||
exitSearchMode,
|
||||
handleSearchInputChange,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown: handleSearchKeyDown,
|
||||
loadMoreRef,
|
||||
} = useScheduleSearch();
|
||||
|
||||
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
||||
const { toast, setToast } = useToast();
|
||||
const scrollContainerRef = useRef(null);
|
||||
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||
|
||||
// 검색 추천 관련 상태
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리
|
||||
const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
|
||||
const SEARCH_LIMIT = 20; // 페이지당 20개
|
||||
const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정)
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
const { ref: loadMoreRef, inView } = useInView({
|
||||
threshold: 0,
|
||||
rootMargin: '100px',
|
||||
});
|
||||
|
||||
// useInfiniteQuery for search
|
||||
const {
|
||||
data: searchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: searchLoading,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['adminScheduleSearch', searchTerm],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.hasMore) {
|
||||
return lastPage.offset + lastPage.schedules.length;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
enabled: !!searchTerm && isSearchMode,
|
||||
});
|
||||
|
||||
// Flatten search results
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchData?.pages) return [];
|
||||
return searchData.pages.flatMap((page) => page.schedules);
|
||||
}, [searchData]);
|
||||
|
||||
// Auto fetch next page when scrolled to bottom
|
||||
// inView가 true로 변경될 때만 fetch (중복 요청 방지)
|
||||
const prevInViewRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// inView가 false→true로 변경될 때만 fetch
|
||||
if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
|
||||
fetchNextPage();
|
||||
}
|
||||
prevInViewRef.current = inView;
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
|
||||
|
||||
// 검색어 자동완성 API 호출 (debounce 적용)
|
||||
useEffect(() => {
|
||||
// 검색어가 비어있으면 초기화
|
||||
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce: 200ms 후에 API 호출
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data.suggestions || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추천 검색어 API 오류:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [originalSearchQuery]);
|
||||
|
||||
// selectedDate가 없으면 오늘 날짜로 초기화
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
|
|
@ -843,14 +777,7 @@ function Schedules() {
|
|||
className="flex items-center gap-3 flex-1"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSearchMode(false);
|
||||
setSearchInput('');
|
||||
setOriginalSearchQuery('');
|
||||
setSearchTerm('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
onClick={exitSearchMode}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} className="text-gray-500" />
|
||||
|
|
@ -864,65 +791,13 @@ function Schedules() {
|
|||
placeholder="일정 검색..."
|
||||
value={searchInput}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setSearchInput(e.target.value);
|
||||
setOriginalSearchQuery(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const newIndex =
|
||||
selectedSuggestionIndex < suggestions.length - 1
|
||||
? selectedSuggestionIndex + 1
|
||||
: 0;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (suggestions[newIndex]) {
|
||||
setSearchInput(suggestions[newIndex]);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const newIndex =
|
||||
selectedSuggestionIndex > 0
|
||||
? selectedSuggestionIndex - 1
|
||||
: suggestions.length - 1;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (suggestions[newIndex]) {
|
||||
setSearchInput(suggestions[newIndex]);
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
if (
|
||||
selectedSuggestionIndex >= 0 &&
|
||||
suggestions[selectedSuggestionIndex]
|
||||
) {
|
||||
setSearchInput(suggestions[selectedSuggestionIndex]);
|
||||
setSearchTerm(suggestions[selectedSuggestionIndex]);
|
||||
} else if (searchInput.trim()) {
|
||||
setSearchTerm(searchInput);
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsSearchMode(false);
|
||||
setSearchInput('');
|
||||
setOriginalSearchQuery('');
|
||||
setSearchTerm('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchInput.trim()) {
|
||||
setSearchTerm(searchInput);
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleSearch(searchInput)}
|
||||
disabled={searchLoading}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -938,12 +813,7 @@ function Schedules() {
|
|||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => {
|
||||
setSearchInput(suggestion);
|
||||
setSearchTerm(suggestion);
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
onClick={() => handleSuggestionSelect(suggestion)}
|
||||
className={`w-full px-4 py-2 text-left text-sm flex items-center gap-3 transition-colors ${
|
||||
index === selectedSuggestionIndex
|
||||
? 'bg-primary/10 text-primary'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue