FeedList.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. 'use client';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import { fetchApi } from '@/lib/utils/client';
  4. import { FeedCard as FeedCardType, FeedTimelineResponse } from '@/types/feed/card';
  5. import FeedCard from './FeedCard';
  6. import '../style.scss';
  7. type Props = {
  8. initialCards: FeedCardType[];
  9. initialTotal: number;
  10. endpoint: string;
  11. perPage?: number;
  12. emptyMessage?: string;
  13. };
  14. export default function FeedList({ initialCards, initialTotal, endpoint, perPage = 20, emptyMessage }: Props) {
  15. const [cards, setCards] = useState<FeedCardType[]>(initialCards);
  16. const [page, setPage] = useState(2);
  17. const [hasMore, setHasMore] = useState(initialCards.length < initialTotal);
  18. const [loading, setLoading] = useState(false);
  19. const sentinelRef = useRef<HTMLDivElement|null>(null);
  20. const [focusIndex, setFocusIndex] = useState<number|null>(null);
  21. const loadMore = useCallback(async () => {
  22. if (loading || !hasMore) {
  23. return;
  24. }
  25. setLoading(true);
  26. try {
  27. const sep = endpoint.includes('?') ? '&' : '?';
  28. const res = await fetchApi<FeedTimelineResponse>(`${endpoint}${sep}page=${page}&perPage=${perPage}`, {
  29. method: 'GET',
  30. silent: true
  31. });
  32. const list = res.data?.list ?? [];
  33. const total = res.data?.total ?? 0;
  34. setCards((prev) => {
  35. const seen = new Set(prev.map((c) => c.postID));
  36. const dedup = list.filter((c) => !seen.has(c.postID));
  37. const next = [...prev, ...dedup];
  38. if (next.length >= total || list.length === 0) {
  39. setHasMore(false);
  40. }
  41. return next;
  42. });
  43. setPage((p) => p + 1);
  44. } catch (err) {
  45. console.error('Feed load failed:', err);
  46. setHasMore(false);
  47. } finally {
  48. setLoading(false);
  49. }
  50. }, [loading, hasMore, endpoint, page, perPage]);
  51. useEffect(() => {
  52. const node = sentinelRef.current;
  53. if (!node || !hasMore) {
  54. return;
  55. }
  56. const observer = new IntersectionObserver(
  57. (entries) => {
  58. if (entries[0].isIntersecting) {
  59. loadMore();
  60. }
  61. },
  62. { rootMargin: '200px' }
  63. );
  64. observer.observe(node);
  65. return () => observer.disconnect();
  66. }, [hasMore, loadMore]);
  67. const scrollToCard = useCallback((index: number) => {
  68. const el = document.querySelector<HTMLElement>(`[data-post-id="${cards[index]?.postID}"]`);
  69. if (el) {
  70. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  71. }
  72. }, [cards]);
  73. // 새 글 게시 후 즉시 prepend (FeedComposer 가 dispatch)
  74. useEffect(() => {
  75. const handlePosted = async () => {
  76. try {
  77. const sep = endpoint.includes('?') ? '&' : '?';
  78. const res = await fetchApi<FeedTimelineResponse>(`${endpoint}${sep}page=1&perPage=${perPage}`, {
  79. method: 'GET',
  80. silent: true
  81. });
  82. const list = res.data?.list ?? [];
  83. if (list.length === 0) {
  84. return;
  85. }
  86. setCards((prev) => {
  87. const seen = new Set(prev.map((c) => c.postID));
  88. const fresh = list.filter((c) => !seen.has(c.postID));
  89. if (fresh.length === 0) {
  90. return prev;
  91. }
  92. return [...fresh, ...prev];
  93. });
  94. if (typeof window !== 'undefined') {
  95. window.scrollTo({ top: 0, behavior: 'smooth' });
  96. }
  97. } catch (err) {
  98. console.error('Feed refresh after post failed:', err);
  99. }
  100. };
  101. window.addEventListener('feed:posted', handlePosted);
  102. return () => window.removeEventListener('feed:posted', handlePosted);
  103. }, [endpoint, perPage]);
  104. // 키보드 네비게이션 (J/K)
  105. useEffect(() => {
  106. const handleKeyDown = (e: KeyboardEvent) => {
  107. if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
  108. return;
  109. }
  110. if (e.key === 'j' || e.key === 'J') {
  111. setFocusIndex((idx) => {
  112. const next = idx === null ? 0 : Math.min(idx + 1, cards.length - 1);
  113. scrollToCard(next);
  114. return next;
  115. });
  116. } else if (e.key === 'k' || e.key === 'K') {
  117. setFocusIndex((idx) => {
  118. const next = idx === null ? 0 : Math.max(idx - 1, 0);
  119. scrollToCard(next);
  120. return next;
  121. });
  122. }
  123. };
  124. window.addEventListener('keydown', handleKeyDown);
  125. return () => window.removeEventListener('keydown', handleKeyDown);
  126. }, [cards.length, scrollToCard]);
  127. if (cards.length === 0) {
  128. return <p className="feed__empty">{emptyMessage ?? '게시물이 없습니다.'}</p>;
  129. }
  130. return (
  131. <div className="feed__list" role="feed">
  132. {cards.map((card, index) => (
  133. <article
  134. key={card.postID}
  135. className={`feed__list-item${focusIndex === index ? ' feed__list-item--focused' : ''}`}
  136. >
  137. <FeedCard card={card} />
  138. </article>
  139. ))}
  140. {hasMore && (
  141. <div ref={sentinelRef} className="feed__sentinel">
  142. {loading ? '준비 중...' : ''}
  143. </div>
  144. )}
  145. </div>
  146. );
  147. }