11'use client' ;
22
3- import { useEffect , useRef } from 'react' ;
3+ import { useEffect , useRef , useState , useMemo } from 'react' ;
44import Link from 'next/link' ;
55import Image from 'next/image' ;
66import {
@@ -62,11 +62,10 @@ function SortBar({ current, onChange, postCount }: SortBarProps) {
6262 key = { value }
6363 onClick = { ( ) => onChange ( value ) }
6464 aria-pressed = { current === value }
65- className = { `flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
66- current === value
65+ className = { `flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${ current === value
6766 ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
6867 : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
69- } `}
68+ } `}
7069 >
7170 { icon }
7271 { label }
@@ -82,6 +81,8 @@ interface TopicFeedProps {
8281 slug : string ;
8382}
8483
84+ const ESTIMATED_ROW_HEIGHT = 140 ; // Approximate height per post item in pixels
85+
8586export default function TopicFeed ( { slug } : TopicFeedProps ) {
8687 const {
8788 topic,
@@ -97,7 +98,28 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
9798 followLoading,
9899 error,
99100 } = useTopicFeed ( slug ) ;
101+
100102 const sentinelRef = useRef < HTMLDivElement > ( null ) ;
103+ const containerRef = useRef < HTMLDivElement > ( null ) ;
104+ const [ scrollTop , setScrollTop ] = useState ( 0 ) ;
105+ const [ containerHeight , setContainerHeight ] = useState ( 600 ) ;
106+
107+ // Track container scroll and viewport height for virtualization
108+ useEffect ( ( ) => {
109+ const handleScroll = ( ) => {
110+ if ( containerRef . current ) {
111+ setScrollTop ( containerRef . current . scrollTop ) ;
112+ }
113+ } ;
114+ const el = containerRef . current ;
115+ if ( el ) {
116+ setContainerHeight ( el . clientHeight || 600 ) ;
117+ el . addEventListener ( 'scroll' , handleScroll , { passive : true } ) ;
118+ }
119+ return ( ) => {
120+ if ( el ) el . removeEventListener ( 'scroll' , handleScroll ) ;
121+ } ;
122+ } , [ ] ) ;
101123
102124 // Infinite scroll via IntersectionObserver
103125 useEffect ( ( ) => {
@@ -113,6 +135,22 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
113135 return ( ) => observer . disconnect ( ) ;
114136 } , [ loadMore ] ) ;
115137
138+ // Virtualization window calculations
139+ const totalPosts = posts . length ;
140+ const buffer = 5 ;
141+ const startIndex = Math . max ( 0 , Math . floor ( scrollTop / ESTIMATED_ROW_HEIGHT ) - buffer ) ;
142+ const endIndex = Math . min (
143+ totalPosts ,
144+ Math . ceil ( ( scrollTop + containerHeight ) / ESTIMATED_ROW_HEIGHT ) + buffer
145+ ) ;
146+
147+ const virtualPosts = useMemo ( ( ) => {
148+ return posts . slice ( startIndex , endIndex ) ;
149+ } , [ posts , startIndex , endIndex ] ) ;
150+
151+ const topSpacerHeight = startIndex * ESTIMATED_ROW_HEIGHT ;
152+ const bottomSpacerHeight = Math . max ( 0 , ( totalPosts - endIndex ) * ESTIMATED_ROW_HEIGHT ) ;
153+
116154 return (
117155 < div className = "space-y-4" >
118156 { /* Topic header */ }
@@ -150,11 +188,10 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
150188 onClick = { toggleFollow }
151189 disabled = { followLoading }
152190 aria-label = { topic . isFollowing ? `Unfollow #${ topic . name } ` : `Follow #${ topic . name } ` }
153- className = { `shrink-0 px-4 py-1.5 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-60 ${
154- topic . isFollowing
191+ className = { `shrink-0 px-4 py-1.5 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-60 ${ topic . isFollowing
155192 ? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400'
156193 : 'bg-blue-600 text-white hover:bg-blue-700'
157- } `}
194+ } `}
158195 >
159196 { followLoading ? '…' : topic . isFollowing ? 'Following' : 'Follow' }
160197 </ button >
@@ -181,8 +218,11 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
181218 { /* Sort bar */ }
182219 < SortBar current = { sort } onChange = { setSort } postCount = { topic ?. postCount } />
183220
184- { /* Posts list */ }
185- < div className = "bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800" >
221+ { /* Posts list container with virtualization */ }
222+ < div
223+ ref = { containerRef }
224+ className = "bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 max-h-[800px] overflow-y-auto relative"
225+ >
186226 { /* Initial loading skeletons */ }
187227 { loading &&
188228 posts . length === 0 &&
@@ -226,8 +266,11 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
226266 </ div >
227267 ) }
228268
229- { /* Post items */ }
230- { posts . map ( ( post ) => (
269+ { /* Virtualized Top Spacer */ }
270+ { topSpacerHeight > 0 && < div style = { { height : `${ topSpacerHeight } px` } } aria-hidden = "true" /> }
271+
272+ { /* Virtualized Post items */ }
273+ { virtualPosts . map ( ( post ) => (
231274 < article
232275 key = { post . id }
233276 className = "p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
@@ -318,6 +361,9 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
318361 </ article >
319362 ) ) }
320363
364+ { /* Virtualized Bottom Spacer */ }
365+ { bottomSpacerHeight > 0 && < div style = { { height : `${ bottomSpacerHeight } px` } } aria-hidden = "true" /> }
366+
321367 { /* Infinite scroll sentinel */ }
322368 { hasMore && < div ref = { sentinelRef } className = "h-4" aria-hidden = "true" /> }
323369
@@ -331,4 +377,4 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
331377 </ div >
332378 </ div >
333379 ) ;
334- }
380+ }
0 commit comments