'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchApi } from '@/lib/utils/client'; import { FeedCard as FeedCardType, FeedTimelineResponse } from '@/types/feed/card'; import FeedCard from './FeedCard'; import '../style.scss'; type Props = { initialCards: FeedCardType[]; initialTotal: number; endpoint: string; perPage?: number; emptyMessage?: string; }; export default function FeedList({ initialCards, initialTotal, endpoint, perPage = 20, emptyMessage }: Props) { const [cards, setCards] = useState(initialCards); const [page, setPage] = useState(2); const [hasMore, setHasMore] = useState(initialCards.length < initialTotal); const [loading, setLoading] = useState(false); const sentinelRef = useRef(null); const [focusIndex, setFocusIndex] = useState(null); const loadMore = useCallback(async () => { if (loading || !hasMore) { return; } setLoading(true); try { const sep = endpoint.includes('?') ? '&' : '?'; const res = await fetchApi(`${endpoint}${sep}page=${page}&perPage=${perPage}`, { method: 'GET', silent: true }); const list = res.data?.list ?? []; const total = res.data?.total ?? 0; setCards((prev) => { const seen = new Set(prev.map((c) => c.postID)); const dedup = list.filter((c) => !seen.has(c.postID)); const next = [...prev, ...dedup]; if (next.length >= total || list.length === 0) { setHasMore(false); } return next; }); setPage((p) => p + 1); } catch (err) { console.error('Feed load failed:', err); setHasMore(false); } finally { setLoading(false); } }, [loading, hasMore, endpoint, page, perPage]); useEffect(() => { const node = sentinelRef.current; if (!node || !hasMore) { return; } const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { loadMore(); } }, { rootMargin: '200px' } ); observer.observe(node); return () => observer.disconnect(); }, [hasMore, loadMore]); const scrollToCard = useCallback((index: number) => { const el = document.querySelector(`[data-post-id="${cards[index]?.postID}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [cards]); // 새 글 게시 후 즉시 prepend (FeedComposer 가 dispatch) useEffect(() => { const handlePosted = async () => { try { const sep = endpoint.includes('?') ? '&' : '?'; const res = await fetchApi(`${endpoint}${sep}page=1&perPage=${perPage}`, { method: 'GET', silent: true }); const list = res.data?.list ?? []; if (list.length === 0) { return; } setCards((prev) => { const seen = new Set(prev.map((c) => c.postID)); const fresh = list.filter((c) => !seen.has(c.postID)); if (fresh.length === 0) { return prev; } return [...fresh, ...prev]; }); if (typeof window !== 'undefined') { window.scrollTo({ top: 0, behavior: 'smooth' }); } } catch (err) { console.error('Feed refresh after post failed:', err); } }; window.addEventListener('feed:posted', handlePosted); return () => window.removeEventListener('feed:posted', handlePosted); }, [endpoint, perPage]); // 키보드 네비게이션 (J/K) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } if (e.key === 'j' || e.key === 'J') { setFocusIndex((idx) => { const next = idx === null ? 0 : Math.min(idx + 1, cards.length - 1); scrollToCard(next); return next; }); } else if (e.key === 'k' || e.key === 'K') { setFocusIndex((idx) => { const next = idx === null ? 0 : Math.max(idx - 1, 0); scrollToCard(next); return next; }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [cards.length, scrollToCard]); if (cards.length === 0) { return

{emptyMessage ?? '게시물이 없습니다.'}

; } return (
{cards.map((card, index) => (
))} {hasMore && (
{loading ? '준비 중...' : ''}
)}
); }