1- import { useState , useEffect } from 'react' ;
2- import { useParams } from 'react-router-dom' ;
1+ import { useState , useEffect , useRef } from 'react' ;
2+ import { useParams , useNavigate } from 'react-router-dom' ;
33import { useDatabase } from '@/hooks/useDatabase' ;
44import { getUtteranceContext , getUtteranceChain } from '@/lib/database/queries' ;
55import { UtteranceCard } from '@/components/reader/UtteranceCard' ;
@@ -13,12 +13,18 @@ export function ReaderPage() {
1313 utteranceId : string ;
1414 } > ( ) ;
1515 const year = parseInt ( yearParam || '1920' ) ;
16+ const navigate = useNavigate ( ) ;
1617
1718 const { loading : dbLoading , error : dbError } = useDatabase ( year ) ;
1819 const [ utterances , setUtterances ] = useState < UtteranceWithPerson [ ] > ( [ ] ) ;
1920 const [ loading , setLoading ] = useState ( true ) ;
2021 const [ loadingMore , setLoadingMore ] = useState ( { top : false , bottom : false } ) ;
2122
23+ const topSentinelRef = useRef < HTMLDivElement > ( null ) ;
24+ const bottomSentinelRef = useRef < HTMLDivElement > ( null ) ;
25+ const containerRef = useRef < HTMLDivElement > ( null ) ;
26+ const utteranceRefs = useRef < Map < string , HTMLDivElement > > ( new Map ( ) ) ;
27+
2228 // Load initial context
2329 useEffect ( ( ) => {
2430 if ( dbLoading !== 'success' || ! utteranceId ) return ;
@@ -112,6 +118,77 @@ export function ReaderPage() {
112118 }
113119 } ;
114120
121+ // Intersection Observer for infinite scroll
122+ useEffect ( ( ) => {
123+ if ( ! topSentinelRef . current || ! bottomSentinelRef . current ) return ;
124+
125+ const observer = new IntersectionObserver (
126+ ( entries ) => {
127+ entries . forEach ( ( entry ) => {
128+ if ( entry . isIntersecting ) {
129+ if ( entry . target === topSentinelRef . current ) {
130+ loadPrevious ( ) ;
131+ } else if ( entry . target === bottomSentinelRef . current ) {
132+ loadNext ( ) ;
133+ }
134+ }
135+ } ) ;
136+ } ,
137+ { threshold : 0.1 , rootMargin : '200px' }
138+ ) ;
139+
140+ observer . observe ( topSentinelRef . current ) ;
141+ observer . observe ( bottomSentinelRef . current ) ;
142+
143+ return ( ) => observer . disconnect ( ) ;
144+ } , [ utterances , loadingMore ] ) ;
145+
146+ // Update URL based on visible utterance
147+ useEffect ( ( ) => {
148+ if ( utterances . length === 0 ) return ;
149+
150+ let timeoutId : ReturnType < typeof setTimeout > | null = null ;
151+
152+ const handleScroll = ( ) => {
153+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
154+
155+ timeoutId = setTimeout ( ( ) => {
156+ // Find the utterance closest to the center of the viewport
157+ const viewportCenter = window . innerHeight / 2 ;
158+ let closestUtterance : UtteranceWithPerson | null = null ;
159+ let closestDistance = Infinity ;
160+
161+ utterances . forEach ( ( utterance ) => {
162+ const element = utteranceRefs . current . get ( utterance . id ) ;
163+ if ( ! element ) return ;
164+
165+ const rect = element . getBoundingClientRect ( ) ;
166+ const elementCenter = rect . top + rect . height / 2 ;
167+ const distance = Math . abs ( elementCenter - viewportCenter ) ;
168+
169+ if ( distance < closestDistance ) {
170+ closestDistance = distance ;
171+ closestUtterance = utterance ;
172+ }
173+ } ) ;
174+
175+ if ( closestUtterance !== null ) {
176+ const newUtteranceId = ( closestUtterance as UtteranceWithPerson ) . id ;
177+ if ( newUtteranceId !== utteranceId ) {
178+ // Update URL without triggering navigation
179+ navigate ( `/${ year } /${ newUtteranceId } ` , { replace : true } ) ;
180+ }
181+ }
182+ } , config . scrollDebounceMs ) ;
183+ } ;
184+
185+ window . addEventListener ( 'scroll' , handleScroll ) ;
186+ return ( ) => {
187+ window . removeEventListener ( 'scroll' , handleScroll ) ;
188+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
189+ } ;
190+ } , [ utterances , year , utteranceId , navigate ] ) ;
191+
115192 if ( dbLoading === 'loading' ) {
116193 return < LoadingSpinner message = { `Loading database for year ${ year } ...` } /> ;
117194 }
@@ -137,7 +214,7 @@ export function ReaderPage() {
137214 }
138215
139216 return (
140- < div className = "max-w-4xl mx-auto" >
217+ < div ref = { containerRef } className = "max-w-4xl mx-auto" >
141218 < div className = "mb-6" >
142219 < h1 className = "text-3xl font-bold text-gray-800 mb-2" >
143220 Parliamentary Debate Reader
@@ -147,42 +224,44 @@ export function ReaderPage() {
147224 </ p >
148225 </ div >
149226
150- { /* Load Previous Button */ }
151- { utterances [ 0 ] ?. prev && (
152- < div className = "mb-4 text-center" >
153- < button
154- onClick = { loadPrevious }
155- disabled = { loadingMore . top }
156- className = "bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
157- >
158- { loadingMore . top ? 'Loading...' : 'Load Previous' }
159- </ button >
160- </ div >
161- ) }
227+ { /* Top sentinel for infinite scroll */ }
228+ < div ref = { topSentinelRef } className = "h-4" >
229+ { loadingMore . top && (
230+ < div className = "text-center py-2" >
231+ < LoadingSpinner message = "Loading previous..." />
232+ </ div >
233+ ) }
234+ </ div >
162235
163236 { /* Utterances */ }
164237 < div className = "space-y-4" >
165238 { utterances . map ( ( utterance ) => (
166- < UtteranceCard
239+ < div
167240 key = { utterance . id }
168- utterance = { utterance }
169- highlighted = { utterance . id === utteranceId }
170- />
241+ ref = { ( el ) => {
242+ if ( el ) {
243+ utteranceRefs . current . set ( utterance . id , el ) ;
244+ } else {
245+ utteranceRefs . current . delete ( utterance . id ) ;
246+ }
247+ } }
248+ >
249+ < UtteranceCard
250+ utterance = { utterance }
251+ highlighted = { utterance . id === utteranceId }
252+ />
253+ </ div >
171254 ) ) }
172255 </ div >
173256
174- { /* Load Next Button */ }
175- { utterances [ utterances . length - 1 ] ?. next && (
176- < div className = "mt-4 text-center" >
177- < button
178- onClick = { loadNext }
179- disabled = { loadingMore . bottom }
180- className = "bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
181- >
182- { loadingMore . bottom ? 'Loading...' : 'Load Next' }
183- </ button >
184- </ div >
185- ) }
257+ { /* Bottom sentinel for infinite scroll */ }
258+ < div ref = { bottomSentinelRef } className = "h-4" >
259+ { loadingMore . bottom && (
260+ < div className = "text-center py-2" >
261+ < LoadingSpinner message = "Loading next..." />
262+ </ div >
263+ ) }
264+ </ div >
186265 </ div >
187266 ) ;
188267}
0 commit comments