| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- '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<FeedCardType[]>(initialCards);
- const [page, setPage] = useState(2);
- const [hasMore, setHasMore] = useState(initialCards.length < initialTotal);
- const [loading, setLoading] = useState(false);
- const sentinelRef = useRef<HTMLDivElement|null>(null);
- const [focusIndex, setFocusIndex] = useState<number|null>(null);
- const loadMore = useCallback(async () => {
- if (loading || !hasMore) {
- return;
- }
- setLoading(true);
- try {
- const sep = endpoint.includes('?') ? '&' : '?';
- const res = await fetchApi<FeedTimelineResponse>(`${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<HTMLElement>(`[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<FeedTimelineResponse>(`${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 <p className="feed__empty">{emptyMessage ?? '게시물이 없습니다.'}</p>;
- }
- return (
- <div className="feed__list" role="feed">
- {cards.map((card, index) => (
- <article
- key={card.postID}
- className={`feed__list-item${focusIndex === index ? ' feed__list-item--focused' : ''}`}
- >
- <FeedCard card={card} />
- </article>
- ))}
- {hasMore && (
- <div ref={sentinelRef} className="feed__sentinel">
- {loading ? '준비 중...' : ''}
- </div>
- )}
- </div>
- );
- }
|