feat: useInfiniteQuery 기반 무한 스크롤 구현 및 UI 개선

- react-infinite-scroll-component를 useInfiniteQuery + useInView로 대체
- Schedule.jsx, AdminSchedule.jsx에 안정적인 무한 스크롤 적용
- source_name에 Link2 아이콘 추가 (카테고리 오른쪽 인라인 표시)
- 멤버 5명 이상일 경우 '프로미스나인'으로 표시 (탈퇴 멤버 고려)
- AdminSchedule 일반 모드에서 members 배열도 확인하여 멤버 표시
- QueryClientProvider 설정 추가 (main.jsx)
This commit is contained in:
caadiq 2026-01-06 19:48:43 +09:00
parent 2278d42026
commit c54de2ba82
7 changed files with 613 additions and 248 deletions

View file

@ -11,8 +11,19 @@ router.get("/", async (req, res) => {
// 검색어가 있으면 Meilisearch 사용 // 검색어가 있으면 Meilisearch 사용
if (search && search.trim()) { if (search && search.trim()) {
const results = await searchSchedules(search.trim()); const offset = parseInt(req.query.offset) || 0;
return res.json(results); const pageLimit = parseInt(req.query.limit) || 20;
const results = await searchSchedules(search.trim(), {
offset,
limit: pageLimit,
});
return res.json({
schedules: results.hits,
total: results.total,
offset: results.offset,
limit: results.limit,
hasMore: results.offset + results.hits.length < results.total,
});
} }
// 날짜 필터 및 제한 조건 구성 // 날짜 필터 및 제한 조건 구성

View file

@ -53,6 +53,11 @@ export async function initMeilisearch() {
}, },
}); });
// 페이징 설정 (기본 1000개 제한 해제)
await index.updatePagination({
maxTotalHits: 10000, // 최대 10000개까지 조회 가능
});
console.log("[Meilisearch] 인덱스 초기화 완료"); console.log("[Meilisearch] 인덱스 초기화 완료");
} catch (error) { } catch (error) {
console.error("[Meilisearch] 초기화 오류:", error.message); console.error("[Meilisearch] 초기화 오류:", error.message);
@ -106,14 +111,15 @@ export async function deleteSchedule(scheduleId) {
} }
/** /**
* 일정 검색 * 일정 검색 (페이징 지원)
*/ */
export async function searchSchedules(query, options = {}) { export async function searchSchedules(query, options = {}) {
try { try {
const index = client.index(SCHEDULE_INDEX); const index = client.index(SCHEDULE_INDEX);
const searchOptions = { const searchOptions = {
limit: options.limit || 50, limit: options.limit || 1000, // 기본 1000개 (Meilisearch 최대)
offset: options.offset || 0, // 페이징용 offset
attributesToRetrieve: ["*"], attributesToRetrieve: ["*"],
}; };
@ -129,10 +135,16 @@ export async function searchSchedules(query, options = {}) {
const results = await index.search(query, searchOptions); const results = await index.search(query, searchOptions);
return results.hits; // 페이징 정보 포함 반환
return {
hits: results.hits,
total: results.estimatedTotalHits, // 전체 결과 수
offset: searchOptions.offset,
limit: searchOptions.limit,
};
} catch (error) { } catch (error) {
console.error("[Meilisearch] 검색 오류:", error.message); console.error("[Meilisearch] 검색 오류:", error.message);
return []; return { hits: [], total: 0, offset: 0, limit: 0 };
} }
} }

View file

@ -8,12 +8,15 @@
"name": "fromis9-frontend", "name": "fromis9-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.16",
"framer-motion": "^11.0.8", "framer-motion": "^11.0.8",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
@ -1128,6 +1131,32 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -2271,6 +2300,33 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-infinite-scroll-component": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.1.tgz",
"integrity": "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==",
"license": "MIT",
"dependencies": {
"throttle-debounce": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/react-intersection-observer": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.0.tgz",
"integrity": "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-ios-time-picker": { "node_modules/react-ios-time-picker": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/react-ios-time-picker/-/react-ios-time-picker-0.2.2.tgz", "resolved": "https://registry.npmjs.org/react-ios-time-picker/-/react-ios-time-picker-0.2.2.tgz",
@ -2623,6 +2679,15 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/throttle-debounce": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz",
"integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View file

@ -9,12 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.16",
"framer-motion": "^11.0.8", "framer-motion": "^11.0.8",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",

View file

@ -1,10 +1,23 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
// React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -1,7 +1,9 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft } from 'lucide-react'; import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function Schedule() { function Schedule() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -36,8 +38,54 @@ function Schedule() {
const [isSearchMode, setIsSearchMode] = useState(false); const [isSearchMode, setIsSearchMode] = useState(false);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]); const SEARCH_LIMIT = 5; // 5
const [searchLoading, setSearchLoading] = 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,
refetch: refetchSearch,
} = useInfiniteQuery({
queryKey: ['scheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`
);
if (!response.ok) throw new Error('Search failed');
return response.json();
},
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]);
const searchTotal = searchData?.pages?.[0]?.total || 0;
// Auto fetch next page when scrolled to bottom
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
// //
useEffect(() => { useEffect(() => {
@ -70,26 +118,6 @@ function Schedule() {
console.error('카테고리 로드 오류:', error); console.error('카테고리 로드 오류:', error);
} }
}; };
// (Meilisearch API )
const searchSchedules = async (term) => {
if (!term.trim()) {
setSearchResults([]);
return;
}
setSearchLoading(true);
try {
const response = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`);
if (response.ok) {
const data = await response.json();
setSearchResults(data);
}
} catch (error) {
console.error('검색 오류:', error);
} finally {
setSearchLoading(false);
}
};
// //
useEffect(() => { useEffect(() => {
@ -465,6 +493,13 @@ function Schedule() {
const eventColor = getScheduleColor(day); const eventColor = getScheduleColor(day);
const dayOfWeek = (firstDay + i) % 7; const dayOfWeek = (firstDay + i) % 7;
const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
// ( , 3)
const daySchedules = schedules.filter(s => {
const scheduleDate = s.date ? s.date.split('T')[0] : '';
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return scheduleDate === dateStr;
}).slice(0, 3);
return ( return (
<button <button
@ -472,17 +507,24 @@ function Schedule() {
onClick={() => selectDate(day)} onClick={() => selectDate(day)}
className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100 className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100
${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''} ${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
${isToday && !isSelected ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''} ${isToday && !isSelected ? 'text-primary font-bold' : ''}
${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''} ${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''} ${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
`} `}
> >
<span>{day}</span> <span>{day}</span>
{/* 점: absolute로 위치 고정하여 글씨 위치에 영향 없음 */} {/* 점: 선택되지 않은 날짜에만 표시, 최대 3개 */}
<span {!isSelected && daySchedules.length > 0 && (
className={`absolute bottom-1 w-1.5 h-1.5 rounded-full ${hasEvent ? '' : 'opacity-0'}`} <span className="absolute bottom-1 flex gap-0.5">
style={{ backgroundColor: isSelected ? 'white' : (eventColor || 'transparent') }} {daySchedules.map((schedule, idx) => (
/> <span
key={idx}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: getCategoryColor(schedule.category_id) }}
/>
))}
</span>
)}
</button> </button>
); );
})} })}
@ -717,110 +759,184 @@ function Schedule() {
</div> </div>
<motion.div <div
key={isSearchMode ? 'search' : 'normal'} id="scheduleScrollContainer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="max-h-[calc(100vh-200px)] overflow-y-auto space-y-4 py-2 pr-2" className="max-h-[calc(100vh-200px)] overflow-y-auto space-y-4 py-2 pr-2"
> >
{loading ? ( {loading ? (
<div className="text-center py-20 text-gray-500">로딩 ...</div> <div className="text-center py-20 text-gray-500">로딩 ...</div>
) : filteredSchedules.length > 0 ? ( ) : filteredSchedules.length > 0 ? (
isSearchMode && searchTerm ? (
/* 검색 모드: useInView 기반 무한 스크롤 */
<>
{filteredSchedules.map((schedule, index) => {
const formatted = formatDate(schedule.date);
const categoryColor = getCategoryColor(schedule.category_id);
const categoryName = getCategoryName(schedule.category_id);
return (
<motion.div
key={`${schedule.id}-search-${index}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
onClick={() => handleScheduleClick(schedule)}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
>
{/* 날짜 영역 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
>
<span className="text-xs font-medium opacity-60">
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
</span>
<span className="text-3xl font-bold">{formatted.day}</span>
<span className="text-sm font-medium opacity-80">{formatted.weekday}</span>
</div>
filteredSchedules.map((schedule, index) => { {/* 스케줄 내용 */}
const formatted = formatDate(schedule.date); <div className="flex-1 p-6 flex flex-col justify-center">
const categoryColor = getCategoryColor(schedule.category_id); <h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
const categoryName = getCategoryName(schedule.category_id);
<div className="flex flex-wrap gap-3 text-base text-gray-500">
return ( {schedule.time && (
<motion.div <span className="flex items-center gap-1">
key={`${schedule.id}-${selectedDate || 'all'}`} <Clock size={16} className="opacity-60" />
initial={{ opacity: 0 }} {schedule.time.slice(0, 5)}
animate={{ opacity: 1 }} </span>
transition={{ delay: Math.min(index, 10) * 0.03 }} )}
<span className="flex items-center gap-1">
onClick={() => handleScheduleClick(schedule)} <Tag size={16} className="opacity-60" />
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
>
{/* 날짜 영역 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
>
{/* 검색 모드일 때 년.월 표시, 일반 모드에서는 월 표시 안함 */}
{isSearchMode && searchTerm && (
<span className="text-xs font-medium opacity-60">
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
</span>
)}
<span className="text-3xl font-bold">{formatted.day}</span>
<span className="text-sm font-medium opacity-80">{formatted.weekday}</span>
</div>
{/* 스케줄 내용 */}
<div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
<div className="flex flex-wrap gap-3 text-base text-gray-500">
{schedule.time && (
<div className="flex items-center gap-1">
<Clock size={16} style={{ color: categoryColor }} />
<span>{schedule.time.slice(0, 5)}</span>
</div>
)}
{categoryName && (
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: categoryColor }}
/>
<span>
{categoryName} {categoryName}
{schedule.source_name && ` · ${schedule.source_name}`}
</span> </span>
{schedule.source_name && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{schedule.source_name}
</span>
)}
</div> </div>
)}
{(() => {
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
if (memberList.length === 0) return null;
if (memberList.length === 5) {
return (
<div className="flex flex-wrap gap-1.5 mt-2">
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
프로미스나인
</span>
</div>
);
}
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
{name}
</span>
))}
</div>
);
})()}
</div>
</motion.div>
);
})}
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
{/* 멤버 태그 (별도 줄) */} )}
{(() => { {!hasNextPage && searchResults.length > 0 && (
const memberList = schedule.members <div className="text-center text-sm text-gray-400">
? schedule.members.map(m => m.name) {searchResults.length} / {searchTotal} 표시 (모두 로드됨)
: (schedule.member_names ? schedule.member_names.split(',') : []); </div>
if (memberList.length === 0) return null; )}
</div>
// 5 '' </>
if (memberList.length >= 5) { ) : (
/* 일반 모드: 기존 렌더링 */
filteredSchedules.map((schedule, index) => {
const formatted = formatDate(schedule.date);
const categoryColor = getCategoryColor(schedule.category_id);
const categoryName = getCategoryName(schedule.category_id);
return (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
onClick={() => handleScheduleClick(schedule)}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
>
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
>
<span className="text-3xl font-bold">{formatted.day}</span>
<span className="text-sm font-medium opacity-80">{formatted.weekday}</span>
</div>
<div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
<div className="flex flex-wrap gap-3 text-base text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={16} className="opacity-60" />
{schedule.time.slice(0, 5)}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{categoryName}
</span>
{schedule.source_name && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{schedule.source_name}
</span>
)}
</div>
{(() => {
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
if (memberList.length === 0) return null;
if (memberList.length === 5) {
return (
<div className="flex flex-wrap gap-1.5 mt-2">
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
프로미스나인
</span>
</div>
);
}
return ( return (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"> {memberList.map((name, i) => (
프로미스나인 <span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
</span> {name}
</span>
))}
</div> </div>
); );
} })()}
</div>
// </motion.div>
return ( );
<div className="flex flex-wrap gap-1.5 mt-2"> })
{memberList.map((name, i) => ( )
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
{name}
</span>
))}
</div>
);
})()}
</div>
</motion.div>
);
})
) : ( ) : (
<div className="text-center py-20 text-gray-500"> <div className="text-center py-20 text-gray-500">
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'} {selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
</div> </div>
)} )}
</motion.div> </div>
</div> </div>
</div> </div>

View file

@ -3,8 +3,10 @@ import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2, LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2
} from 'lucide-react'; } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
import Tooltip from '../../../components/Tooltip'; import Tooltip from '../../../components/Tooltip';
@ -35,8 +37,53 @@ function AdminSchedule() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [searchResults, setSearchResults] = useState([]); const SEARCH_LIMIT = 5; // 5
const [searchLoading, setSearchLoading] = 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 }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`
);
if (!response.ok) throw new Error('Search failed');
return response.json();
},
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]);
const searchTotal = searchData?.pages?.[0]?.total || 0;
// Auto fetch next page when scrolled to bottom
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
// selectedDate // selectedDate
useEffect(() => { useEffect(() => {
@ -218,24 +265,6 @@ function AdminSchedule() {
setLoading(false); setLoading(false);
} }
}; };
// (Meilisearch API )
const searchSchedules = async (term) => {
if (!term.trim()) {
setSearchResults([]);
return;
}
setSearchLoading(true);
try {
const res = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`);
const data = await res.json();
setSearchResults(data);
} catch (error) {
console.error('검색 오류:', error);
} finally {
setSearchLoading(false);
}
};
// //
@ -726,6 +755,13 @@ function AdminSchedule() {
const eventColor = getScheduleColor(day); const eventColor = getScheduleColor(day);
const dayOfWeek = (firstDay + i) % 7; const dayOfWeek = (firstDay + i) % 7;
const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
// ( , 3)
const daySchedules = schedules.filter(s => {
const scheduleDate = s.date ? s.date.split('T')[0] : '';
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return scheduleDate === dateStr;
}).slice(0, 3);
return ( return (
<button <button
@ -735,17 +771,24 @@ function AdminSchedule() {
className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative
${isSearchMode ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-100'} ${isSearchMode ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-100'}
${isSelected && !isSearchMode ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''} ${isSelected && !isSearchMode ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
${isToday && !isSelected ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''} ${isToday && !isSelected ? 'text-primary font-bold' : ''}
${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''} ${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''} ${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
`} `}
> >
<span>{day}</span> <span>{day}</span>
{/* 점: absolute로 위치 고정하여 글씨 위치에 영향 없음 */} {/* 점: 선택되지 않은 날짜에만 표시, 최대 3개 */}
<span {!isSelected && daySchedules.length > 0 && (
className={`absolute bottom-1 w-1.5 h-1.5 rounded-full ${hasEvent ? '' : 'opacity-0'}`} <span className="absolute bottom-1 flex gap-0.5">
style={{ backgroundColor: isSelected ? 'white' : (eventColor || 'transparent') }} {daySchedules.map((schedule, idx) => (
/> <span
key={idx}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
/>
))}
</span>
)}
</button> </button>
); );
})} })}
@ -975,113 +1018,215 @@ function AdminSchedule() {
<p>등록된 일정이 없습니다</p> <p>등록된 일정이 없습니다</p>
</div> </div>
) : ( ) : (
<div className="max-h-[calc(100vh-280px)] overflow-y-auto divide-y divide-gray-100 py-2"> <div
id="adminScheduleScrollContainer"
{filteredSchedules.map((schedule, index) => ( className="max-h-[calc(100vh-280px)] overflow-y-auto divide-y divide-gray-100 py-2"
<motion.div >
key={`${schedule.id}-${selectedDate || 'all'}`} {isSearchMode && searchTerm ? (
/* 검색 모드: useInView 기반 무한 스크롤 */
initial={{ opacity: 0 }} <>
animate={{ opacity: 1 }} {filteredSchedules.map((schedule, index) => (
transition={{ delay: Math.min(index, 10) * 0.03 }} <motion.div
key={`${schedule.id}-search-${index}`}
className="p-6 hover:bg-gray-50 transition-colors group" initial={{ opacity: 0 }}
> animate={{ opacity: 1 }}
<div className="flex items-start gap-4"> transition={{ delay: Math.min(index, 10) * 0.03 }}
{/* 날짜 */} className="p-6 hover:bg-gray-50 transition-colors group"
<div className="w-20 text-center flex-shrink-0"> >
{/* 검색 모드일 때 년/월 표시 */} <div className="flex items-start gap-4">
{isSearchMode && searchTerm && ( <div className="w-20 text-center flex-shrink-0">
<div className="text-xs text-gray-400 mb-0.5"> <div className="text-xs text-gray-400 mb-0.5">
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
</div>
<div className="text-2xl font-bold text-gray-900">
{new Date(schedule.date).getDate()}
</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
</div>
</div> </div>
)}
<div className="text-2xl font-bold text-gray-900">
{new Date(schedule.date).getDate()}
</div>
<div className="text-sm text-gray-500">
{new Date(schedule.date).toLocaleDateString('ko-KR', { weekday: 'short' })}
</div>
</div>
{/* 내용 */} <div
<div className="flex-1 min-w-0"> className="w-1.5 rounded-full flex-shrink-0 self-stretch"
<div className="flex items-center gap-2 mb-1"> style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
<span />
className="px-2 py-0.5 text-xs font-medium rounded-full text-white"
style={{ backgroundColor: schedule.category_color || '#808080' }} <div className="flex-1 min-w-0">
> <h3 className="font-semibold text-gray-900 truncate">{schedule.title}</h3>
{schedule.category_name || '미지정'} <div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
</span> {schedule.time && (
{schedule.source_name && ( <span className="flex items-center gap-1">
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600"> <Clock size={14} />
{schedule.source_name} {schedule.time.slice(0, 5)}
</span>
)}
<span className="text-sm text-gray-400">{schedule.time?.slice(0, 5)}</span>
</div>
<h4 className="font-medium text-gray-900 mb-2">{schedule.title}</h4>
{schedule.description && (
<p className="text-sm text-gray-500 mb-1">{schedule.description}</p>
)}
{/* 멤버 태그 */}
{(() => {
// members member_names
const memberList = schedule.members?.length > 0
? schedule.members
: schedule.member_names
? schedule.member_names.split(',').filter(n => n.trim()).map((name, idx) => ({ id: idx, name: name.trim() }))
: [];
if (memberList.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span> </span>
) : ( )}
memberList.map((member) => ( <span className="flex items-center gap-1">
<span key={member.id} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> <Tag size={14} />
{member.name} {categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
</span> </span>
)) {schedule.source_name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source_name}
</span>
)} )}
</div> </div>
); {schedule.member_names && (
})()} <div className="flex flex-wrap gap-1.5 mt-2">
{schedule.member_names.split(',').length >= 5 ? (
</div> <span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
{/* 액션 버튼 */} </span>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> ) : (
{schedule.source_url && ( schedule.member_names.split(',').map((name, i) => (
<a <span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
href={schedule.source_url} {name.trim()}
target="_blank" </span>
rel="noopener noreferrer" ))
onClick={(e) => e.stopPropagation()} )}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" </div>
> )}
<ExternalLink size={18} /> </div>
</a>
)}
<button
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source_url && (
<a
href={schedule.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
</motion.div>
))}
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!hasNextPage && searchResults.length > 0 && (
<div className="text-center text-sm text-gray-400">
{searchResults.length} / {searchTotal} 표시 (모두 로드됨)
</div>
)}
</div> </div>
</motion.div> </>
))} ) : (
/* 일반 모드: 기존 렌더링 */
filteredSchedules.map((schedule, index) => (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
className="p-6 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
<div className="text-2xl font-bold text-gray-900">
{new Date(schedule.date).getDate()}
</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
</div>
</div>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{schedule.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={14} />
{schedule.time.slice(0, 5)}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
</span>
{schedule.source_name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source_name}
</span>
)}
</div>
{(() => {
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
if (memberList.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{name.trim()}
</span>
))
)}
</div>
);
})()}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source_url && (
<a
href={schedule.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
</motion.div>
))
)}
</div> </div>
)} )}
</div> </div>