From 90f5a9a90ac14d39c9c18f52a248365686ff6631 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 11 Jan 2026 15:58:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(Schedule):=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20UI=20=ED=94=84=EB=A1=9C=ED=86=A0?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PC/Admin 스케줄 페이지에 검색어 추천 드롭다운 추가 - 3영역 검색창 레이아웃 (뒤로가기 / 입력 / 검색 버튼) - 방향키로 추천 검색어 선택 시 입력창 반영 (유튜브 스타일) - 외부 클릭 시 드롭다운 닫기 - 검색 모드 진입 시 기존 카테고리 유지 - 검색 모드 종료 시 스크롤 위치 초기화 - 전환 애니메이션 개선 (scale + opacity) --- frontend/src/pages/pc/admin/AdminSchedule.jsx | 6 +- frontend/src/pages/pc/public/Schedule.jsx | 237 ++++++++++++++---- 2 files changed, 188 insertions(+), 55 deletions(-) diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 8ff7633..c7f2c7d 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -536,13 +536,15 @@ function AdminSchedule() { // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { + // 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용 const source = (isSearchMode && searchTerm) ? searchResults : schedules; const counts = new Map(); let total = 0; source.forEach(s => { - // 검색 모드가 아닐 때만 선택된 날짜 기준으로 필터링 - if (!isSearchMode && selectedDate) { + // 검색 모드에서 검색어가 있을 때는 전체 대상 + // 그 외에는 선택된 날짜 기준으로 필터링 + if (!(isSearchMode && searchTerm) && selectedDate) { const scheduleDate = formatDate(s.date); if (scheduleDate !== selectedDate) return; } diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index 22066d7..85abe11 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; -import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react'; +import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2, X } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; @@ -37,11 +37,15 @@ function Schedule() { const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); const categoryRef = useRef(null); const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너 + const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용) // 검색 상태 const [isSearchMode, setIsSearchMode] = useState(false); - const [searchInput, setSearchInput] = useState(''); + const [searchInput, setSearchInput] = useState(''); // 입력창에 표시되는 값 + const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 사용자가 직접 입력한 원본 값 (필터링용) const [searchTerm, setSearchTerm] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정) @@ -138,6 +142,11 @@ function Schedule() { if (categoryRef.current && !categoryRef.current.contains(event.target)) { setShowCategoryTooltip(false); } + // 검색 추천 드롭다운 외부 클릭 시 닫기 + if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) { + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + } }; document.addEventListener('mousedown', handleClickOutside); @@ -299,14 +308,16 @@ function Schedule() { // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { + // 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용 const source = (isSearchMode && searchTerm) ? searchResults : schedules; const counts = new Map(); let total = 0; source.forEach(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; - // 검색 모드가 아닐 때만 선택된 날짜 기준으로 필터링 - if (!isSearchMode && selectedDate) { + // 검색 모드에서 검색어가 있을 때는 전체 대상 + // 그 외에는 선택된 날짜 기준으로 필터링 + if (!(isSearchMode && searchTerm) && selectedDate) { if (scheduleDate !== selectedDate) return; } @@ -688,67 +699,180 @@ function Schedule() { {/* 스케줄 리스트 */}
{/* 헤더 */} -
+
{isSearchMode ? ( /* 검색 모드 - 밑줄 스타일 */ - -
- setSearchInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setSearchTerm(searchInput); - } else if (e.key === 'Escape') { + {/* 검색창 컨테이너 - 화살표와 검색창 일체형 */} +
+
+ {/* 뒤로가기 영역 */} + + + {/* 검색 입력 영역 */} +
+ { + setSearchInput(e.target.value); + setOriginalSearchQuery(e.target.value); // 원본 쿼리도 업데이트 + setShowSuggestions(true); + setSelectedSuggestionIndex(-1); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={(e) => { + // 필터링은 원본 쿼리 기준으로 유지 + 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); + // 스크롤 위치 초기화 + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + } + }} + className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm" + /> + {/* 입력 지우기 버튼 - 항상 공간 차지, 입력 있을 때만 보임 */} + +
+ + {/* 검색 버튼 영역 */} + +
+ + {/* 검색어 추천 드롭다운 */} + {showSuggestions && originalSearchQuery.length > 0 && ( +
+ {(() => { + const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s => + s.toLowerCase().includes(originalSearchQuery.toLowerCase()) + ).slice(0, 7); + + if (dummySuggestions.length === 0) { + return ( +
+ 추천 검색어가 없습니다 +
+ ); + } + + return dummySuggestions.map((suggestion, index) => ( + + )); + })()} +
+ )}
- ) : ( /* 일반 모드 */
+ !isSearchMode && ( + + {selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'} + + ) )}