11'use client' ;
22
33import type { ReactElement , UIEventHandler } from 'react' ;
4- import { useCallback , useMemo , useRef , useState } from 'react' ;
4+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
55import clsx from 'clsx' ;
66import Image from 'next/image' ;
77
@@ -38,6 +38,7 @@ type Props = {
3838
3939export default function GithubUserList ( { t, initialData} : Props ) : ReactElement {
4040 const tBodyRef = useRef < HTMLTableSectionElement > ( null ) ;
41+ const sentinelRef = useRef < HTMLDivElement > ( null ) ;
4142 const [ data , setData ] = useState ( initialData ) ;
4243 const [ selectedTier , setSelectedTier ] = useState < Tier | null > ( null ) ;
4344 const [ tierData , setTierData ] = useState < UserListItem [ ] > ( [ ] ) ;
@@ -47,6 +48,9 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
4748 ? new Date ( initialData ?. [ initialData ?. length - 1 ] ?. createdAt )
4849 : null ,
4950 ) ;
51+ const [ isLoadingMore , setIsLoadingMore ] = useState ( false ) ;
52+ const [ hasMore , setHasMore ] = useState ( true ) ;
53+ const loadMoreRef = useRef < ( ( ) => Promise < void > ) | null > ( null ) ;
5054
5155 const handleTierSelect = useCallback ( async ( tier : Tier | null ) => {
5256 setSelectedTier ( tier ) ;
@@ -81,93 +85,134 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
8185 ( ) => [
8286 {
8387 id : 'login' ,
84- headerClassName : 'w-6/12 py-[12px]' ,
85- cellClassName : 'w-6/12 h-[50px] py-[8px] text-default' ,
88+ headerClassName : 'flex-1 py-[12px] min-w-[150px ]' ,
89+ cellClassName : 'flex-1 h-[50px] py-[8px] text-default min-w-[150px] ' ,
8690 header : ( ) => (
8791 < H5 fontWeight = "semibold" className = "text-start" >
8892 { t . githubUsername }
8993 </ H5 >
9094 ) ,
9195 cell : ( { login, avatarUrl} ) => (
92- < div className = "text-start flex gap-[8px] items-center" >
96+ < div className = "text-start flex gap-[8px] items-center min-w-0 " >
9397 < Image
9498 alt = "avatar"
9599 src = { avatarUrl }
96100 width = { 20 }
97101 height = { 20 }
98- className = "rounded-full"
102+ className = "rounded-full shrink-0 "
99103 />
100- < H4 > { login } </ H4 >
104+ < H4 className = "truncate" > { login } </ H4 >
101105 </ div >
102106 ) ,
103107 } ,
104108 {
105109 id : 'tierName' ,
106- headerClassName : 'w-3/12 py-[12px]' ,
107- cellClassName : 'text-start w-3/12 h-[50px] py-[8px]' ,
110+ headerClassName : 'w-[120px] max-[480px]:w-[40px] py-[12px] shrink-0 ' ,
111+ cellClassName : 'w-[120px] max-[480px]:w-[40px] h-[50px] py-[8px] shrink-0 ' ,
108112 header : ( ) => (
109- < H5 fontWeight = "semibold" className = "text-start text-basic" >
113+ < H5 fontWeight = "semibold" className = "text-start text-basic max-[480px]:hidden " >
110114 { t . tier }
111115 </ H5 >
112116 ) ,
113117 cell : ( { tierName} ) => < TierRowItem tier = { tierName as Tier } /> ,
114118 } ,
115119 {
116120 id : 'score' ,
117- headerClassName : 'w-3/12 py-[12px]' ,
118- cellClassName : 'text-start w-3/12 h-[50px] py-[8px]' ,
121+ headerClassName : 'w-[80px] max-[480px]:w-[50px] py-[12px] shrink-0 justify-center ' ,
122+ cellClassName : 'w-[80px] max-[480px]:w-[50px] h-[50px] py-[8px] shrink-0 justify-center ' ,
119123 header : ( ) => (
120- < H5 fontWeight = "semibold" className = "text-start text-basic" >
124+ < H5 fontWeight = "semibold" className = "text-center text-basic" >
121125 { t . score }
122126 </ H5 >
123127 ) ,
124- cell : ( { score} ) => < div className = "text-start text-basic" > { score } </ div > ,
128+ cell : ( { score} ) => < div className = "text-center text-basic" > { score } </ div > ,
125129 } ,
126130 ] ,
127131 [ t . githubUsername , t . score , t . tier ] ,
128132 ) ;
129133
130- const handleScroll : UIEventHandler < HTMLTableSectionElement > = async (
131- e ,
132- ) : Promise < void > => {
133- const hasEndReached =
134- Math . ceil ( e . currentTarget . scrollTop + e . currentTarget . clientHeight ) >=
135- e . currentTarget . scrollHeight ;
134+ const loadMore = useCallback ( async ( ) => {
135+ if ( ! cursor || isLoadingMore || selectedTier || ! hasMore ) return ;
136136
137- if ( hasEndReached ) {
138- if ( ! cursor ) {
137+ setIsLoadingMore ( true ) ;
138+ try {
139+ const { users} = await fetchRecentList ( {
140+ pluginId : 'dooboo-github' ,
141+ take : 20 ,
142+ cursor,
143+ } ) ;
144+
145+ // No more data from API
146+ if ( ! users || users . length === 0 ) {
147+ setHasMore ( false ) ;
139148 return ;
140149 }
141150
142- try {
143- const { users} = await fetchRecentList ( {
144- pluginId : 'dooboo-github' ,
145- take : 20 ,
146- cursor,
147- } ) ;
151+ // Less than requested means end of data
152+ if ( users . length < 20 ) {
153+ setHasMore ( false ) ;
154+ }
155+
156+ setData ( ( prevData ) => {
157+ const existingLogins = new Set ( prevData . map ( ( u ) => u . login ) ) ;
158+ const filteredUsers = users . filter ( ( el ) => ! existingLogins . has ( el . login ) ) ;
148159
149- let nextCursor : Date | null = null ;
150- setData ( ( prevData ) => {
151- const filteredUsers = users . filter (
152- ( el ) => ! prevData . some ( ( existing ) => existing . login === el . login ) ,
153- ) ;
154- if ( filteredUsers . length === 0 ) return prevData ;
155- nextCursor = new Date (
156- filteredUsers [ filteredUsers . length - 1 ] . createdAt ,
157- ) ;
158- return [ ...prevData , ...filteredUsers ] ;
159- } ) ;
160- if ( nextCursor ) {
161- setCursor ( nextCursor ) ;
160+ if ( filteredUsers . length === 0 ) {
161+ return prevData ;
162162 }
163- } catch ( error ) {
164- console . error ( 'Failed to fetch more users:' , error ) ;
163+
164+ return [ ...prevData , ...filteredUsers ] ;
165+ } ) ;
166+
167+ // Update cursor based on the last user from API response
168+ const lastUser = users [ users . length - 1 ] ;
169+ if ( lastUser ) {
170+ setCursor ( new Date ( lastUser . createdAt ) ) ;
165171 }
172+ } catch ( error ) {
173+ console . error ( 'Failed to fetch more users:' , error ) ;
174+ } finally {
175+ setIsLoadingMore ( false ) ;
176+ }
177+ } , [ cursor , isLoadingMore , selectedTier , hasMore ] ) ;
178+
179+ // Keep loadMore ref updated
180+ loadMoreRef . current = loadMore ;
181+
182+ // Intersection Observer for infinite scroll (works on both mobile and desktop)
183+ useEffect ( ( ) => {
184+ const sentinel = sentinelRef . current ;
185+ if ( ! sentinel ) return ;
186+
187+ const observer = new IntersectionObserver (
188+ ( entries ) => {
189+ if ( entries [ 0 ] . isIntersecting ) {
190+ loadMoreRef . current ?.( ) ;
191+ }
192+ } ,
193+ {
194+ root : null ,
195+ rootMargin : '100px' ,
196+ threshold : 0 ,
197+ } ,
198+ ) ;
199+
200+ observer . observe ( sentinel ) ;
201+ return ( ) => observer . disconnect ( ) ;
202+ } , [ ] ) ;
203+
204+ const handleScroll : UIEventHandler < HTMLDivElement > = ( e ) => {
205+ const hasEndReached =
206+ Math . ceil ( e . currentTarget . scrollTop + e . currentTarget . clientHeight ) >=
207+ e . currentTarget . scrollHeight ;
208+
209+ if ( hasEndReached ) {
210+ loadMore ( ) ;
166211 }
167212 } ;
168213
169214 return (
170- < div className = "flex-1 flex flex-col mx-6 mb-12 max-[480px]:mx-4 max-[480px]:mb-8 overflow-hidden" >
215+ < div className = "flex-1 flex flex-col mx-6 mb-12 max-[480px]:mx-4 max-[480px]:mb-8 overflow-hidden max-[768px]:overflow-visible " >
171216 { /* Tier filter labels */ }
172217 < div
173218 className = { clsx (
@@ -227,14 +272,16 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
227272 { /* Data table */ }
228273 < div
229274 className = { clsx (
230- 'flex-1 overflow-y-scroll' ,
275+ 'flex-1 max-[768px]:flex-none' ,
276+ 'block w-full' ,
277+ 'overflow-x-auto' ,
278+ 'overflow-y-auto max-[768px]:overflow-y-visible' ,
231279 'rounded-[20px]' ,
232280 'bg-black/10 dark:bg-white/5' ,
233281 'backdrop-blur-xl' ,
234282 'border border-black/20 dark:border-white/10' ,
235283 'shadow-[0_8px_32px_0_rgba(31,38,135,0.15)]' ,
236284 'max-[480px]:rounded-[16px]' ,
237- styles . scrollable ,
238285 'transition-opacity duration-300' ,
239286 isLoadingTier && 'opacity-50' ,
240287 ) }
@@ -248,14 +295,22 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
248295 const login = user . login ;
249296 window . open ( `/stats/${ login } ` , '_blank' , 'noopener' ) ;
250297 } }
251- className = "p-6 max-[480px]:p-4"
298+ className = "p-6 max-[480px]:p-4 w-full min-w-[500px] "
252299 classNames = { {
253300 tHead :
254301 'bg-paper-light dark:bg-paper-dark backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]' ,
255302 tBodyRow :
256303 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1' ,
257304 } }
258305 />
306+ { /* Sentinel for infinite scroll */ }
307+ { ! selectedTier && (
308+ < div
309+ ref = { sentinelRef }
310+ className = "h-[1px] w-full"
311+ aria-hidden = "true"
312+ />
313+ ) }
259314 </ div >
260315 </ div >
261316 ) ;
0 commit comments