feat(AdminSchedule): 검색어 추천 드롭다운 추가

- 검색 버튼을 초록색 돋보기 아이콘으로 변경
- 애니메이션 scale+opacity로 개선
- 검색 모드에서 빈 일정 메시지 숨김
- 추천 검색어 드롭다운 (유튜브 스타일)
- 방향키 선택, 외부 클릭 닫기 지원
- 검색 모드 진입 시 기존 카테고리 유지
This commit is contained in:
caadiq 2026-01-11 16:08:22 +09:00
parent 90f5a9a90a
commit 09706e42e3

View file

@ -152,6 +152,13 @@ function AdminSchedule() {
const [user, setUser] = useState(null);
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 SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 100; // ( )
@ -402,6 +409,11 @@ function AdminSchedule() {
if (categoryTooltipRef.current && !categoryTooltipRef.current.contains(event.target)) {
setShowCategoryTooltip(false);
}
//
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
if (showYearMonthPicker || showCategoryTooltip) {
@ -951,35 +963,81 @@ function AdminSchedule() {
/* 검색 모드 */
<motion.div
key="search-mode"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex items-center gap-3 flex-1"
>
<button
onClick={() => {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft size={20} className="text-gray-500" />
</button>
{/* 검색 입력 컨테이너 (드롭다운 포함) */}
<div className="flex-1 relative" ref={searchContainerRef}>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="일정 검색..."
value={searchInput}
autoFocus
onChange={(e) => setSearchInput(e.target.value)}
onChange={(e) => {
setSearchInput(e.target.value);
setOriginalSearchQuery(e.target.value);
setShowSuggestions(true);
setSelectedSuggestionIndex(-1);
}}
onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
//
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
).slice(0, 7);
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex = selectedSuggestionIndex < dummySuggestions.length - 1
? selectedSuggestionIndex + 1
: 0;
setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex = selectedSuggestionIndex > 0
? selectedSuggestionIndex - 1
: dummySuggestions.length - 1;
setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]);
}
} else if (e.key === 'Enter') {
if (selectedSuggestionIndex >= 0 && dummySuggestions[selectedSuggestionIndex]) {
setSearchInput(dummySuggestions[selectedSuggestionIndex]);
setSearchTerm(dummySuggestions[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);
setSearchResults([]);
}
}}
@ -987,22 +1045,67 @@ function AdminSchedule() {
/>
<button
onClick={() => {
if (searchInput.trim()) {
setSearchTerm(searchInput);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
}}
disabled={searchLoading}
className="px-4 py-1.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
{searchLoading ? '...' : '검색'}
<Search size={20} className="text-primary" />
</button>
</div>
{/* 검색어 추천 드롭다운 */}
{showSuggestions && originalSearchQuery.length > 0 && (
<div className="absolute top-full left-0 right-8 mt-2 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 overflow-hidden">
{(() => {
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
).slice(0, 7);
if (dummySuggestions.length === 0) {
return (
<div className="px-4 py-3 text-sm text-gray-400 text-center">
추천 검색어가 없습니다
</div>
);
}
return dummySuggestions.map((suggestion, index) => (
<button
key={suggestion}
onClick={() => {
setSearchInput(suggestion);
setSearchTerm(suggestion);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
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'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<Search size={14} className="text-gray-400 shrink-0" />
<span>{suggestion}</span>
</button>
));
})()}
</div>
)}
</div>
</motion.div>
) : (
/* 일반 모드 */
<motion.div
key="normal-mode"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex items-center gap-3 flex-1"
>
<button
@ -1071,10 +1174,18 @@ function AdminSchedule() {
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
</div>
) : filteredSchedules.length === 0 ? (
<div className="text-center py-16 text-gray-500">
//
!isSearchMode && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="text-center py-16 text-gray-500"
>
<Calendar size={48} className="mx-auto mb-4 text-gray-300" />
<p>등록된 일정이 없습니다</p>
</div>
</motion.div>
)
) : (
<div
ref={scrollContainerRef}