11import { createFileRoute , Link } from '@tanstack/react-router'
2- import { useQuery } from 'convex/react'
3- import { useEffect , useMemo , useState } from 'react'
2+ import { useAction , useQuery } from 'convex/react'
3+ import { useEffect , useMemo , useRef , useState } from 'react'
44import { api } from '../../../convex/_generated/api'
55import type { Doc } from '../../../convex/_generated/dataModel'
66import { SkillCard } from '../../components/SkillCard'
77
88const sortKeys = [ 'newest' , 'downloads' , 'installs' , 'stars' , 'name' , 'updated' ] as const
9+ const pageSize = 50
910type SortKey = ( typeof sortKeys ) [ number ]
1011type SortDir = 'asc' | 'desc'
1112
@@ -41,29 +42,107 @@ export function SkillsIndex() {
4142 const view = search . view ?? 'list'
4243 const highlightedOnly = search . highlighted ?? false
4344 const [ query , setQuery ] = useState ( search . q ?? '' )
45+ const searchSkills = useAction ( api . search . searchSkills )
46+ const [ pages , setPages ] = useState <
47+ Array < { skill : Doc < 'skills' > ; latestVersion : Doc < 'skillVersions' > | null } >
48+ > ( [ ] )
49+ const [ cursor , setCursor ] = useState < string | null > ( null )
50+ const [ nextCursor , setNextCursor ] = useState < string | null > ( null )
51+ const [ searchResults , setSearchResults ] = useState <
52+ Array < { skill : Doc < 'skills' > ; version : Doc < 'skillVersions' > | null ; score : number } >
53+ > ( [ ] )
54+ const [ searchLimit , setSearchLimit ] = useState ( pageSize )
55+ const [ isSearching , setIsSearching ] = useState ( false )
56+ const searchRequest = useRef ( 0 )
4457
45- const items = useQuery ( api . skills . listWithLatest , { limit : 200 } ) as
46- | Array < { skill : Doc < 'skills' > ; latestVersion : Doc < 'skillVersions' > | null } >
58+ const trimmedQuery = useMemo ( ( ) => query . trim ( ) , [ query ] )
59+ const hasQuery = trimmedQuery . length > 0
60+
61+ const listPage = useQuery (
62+ api . skills . listPublicPage ,
63+ hasQuery ? 'skip' : { cursor : cursor ?? undefined , limit : pageSize } ,
64+ ) as
65+ | {
66+ items : Array < { skill : Doc < 'skills' > ; latestVersion : Doc < 'skillVersions' > | null } >
67+ nextCursor : string | null
68+ }
4769 | undefined
48- const isLoadingSkills = items === undefined
70+ const isLoadingList = ! hasQuery && pages . length === 0 && listPage === undefined
4971
5072 useEffect ( ( ) => {
5173 setQuery ( search . q ?? '' )
5274 } , [ search . q ] )
5375
54- const filtered = useMemo ( ( ) => {
55- const value = query . trim ( ) . toLowerCase ( )
56- const all = ( items ?? [ ] ) . filter ( ( entry ) =>
57- highlightedOnly ? entry . skill . batch === 'highlighted' : true ,
58- )
59- if ( ! value ) return all
60- return all . filter ( ( entry ) => {
61- const skill = entry . skill
62- if ( skill . slug . toLowerCase ( ) . includes ( value ) ) return true
63- if ( skill . displayName . toLowerCase ( ) . includes ( value ) ) return true
64- return ( skill . summary ?? '' ) . toLowerCase ( ) . includes ( value )
65- } )
66- } , [ highlightedOnly , query , items ] )
76+ useEffect ( ( ) => {
77+ if ( hasQuery ) return
78+ setPages ( [ ] )
79+ setCursor ( null )
80+ setNextCursor ( null )
81+ } , [ hasQuery ] )
82+
83+ useEffect ( ( ) => {
84+ if ( hasQuery || ! listPage ) return
85+ setNextCursor ( listPage . nextCursor )
86+ setPages ( ( prev ) => ( cursor ? [ ...prev , ...listPage . items ] : listPage . items ) )
87+ } , [ cursor , hasQuery , listPage ] )
88+
89+ useEffect ( ( ) => {
90+ if ( ! hasQuery ) {
91+ setSearchResults ( [ ] )
92+ setIsSearching ( false )
93+ return
94+ }
95+ setSearchResults ( [ ] )
96+ setSearchLimit ( pageSize )
97+ } , [ hasQuery , highlightedOnly , trimmedQuery ] )
98+
99+ useEffect ( ( ) => {
100+ if ( ! hasQuery ) return
101+ searchRequest . current += 1
102+ const requestId = searchRequest . current
103+ setIsSearching ( true )
104+ const handle = window . setTimeout ( ( ) => {
105+ void ( async ( ) => {
106+ try {
107+ const data = ( await searchSkills ( {
108+ query : trimmedQuery ,
109+ highlightedOnly,
110+ limit : searchLimit ,
111+ } ) ) as Array < {
112+ skill : Doc < 'skills' >
113+ version : Doc < 'skillVersions' > | null
114+ score : number
115+ } >
116+ if ( requestId === searchRequest . current ) {
117+ setSearchResults ( data )
118+ }
119+ } finally {
120+ if ( requestId === searchRequest . current ) {
121+ setIsSearching ( false )
122+ }
123+ }
124+ } ) ( )
125+ } , 220 )
126+ return ( ) => window . clearTimeout ( handle )
127+ } , [ hasQuery , highlightedOnly , searchLimit , searchSkills , trimmedQuery ] )
128+
129+ const baseItems = useMemo ( ( ) => {
130+ if ( hasQuery ) {
131+ return searchResults . map ( ( entry ) => ( {
132+ skill : entry . skill ,
133+ latestVersion : entry . version ,
134+ } ) )
135+ }
136+ return pages
137+ } , [ hasQuery , pages , searchResults ] )
138+
139+ const filtered = useMemo (
140+ ( ) =>
141+ baseItems . filter ( ( entry ) =>
142+ highlightedOnly ? entry . skill . batch === 'highlighted' : true ,
143+ ) ,
144+ [ baseItems , highlightedOnly ] ,
145+ )
67146
68147 const sorted = useMemo ( ( ) => {
69148 const multiplier = dir === 'asc' ? 1 : - 1
@@ -94,9 +173,15 @@ export function SkillsIndex() {
94173 } , [ dir , filtered , sort ] )
95174
96175 const showing = sorted . length
97- const total = items ?. filter ( ( entry ) =>
98- highlightedOnly ? entry . skill . batch === 'highlighted' : true ,
99- ) . length
176+ const isLoadingSkills = hasQuery
177+ ? isSearching && searchResults . length === 0
178+ : isLoadingList
179+ const canLoadMore = hasQuery
180+ ? ! isSearching && searchResults . length === searchLimit && searchResults . length > 0
181+ : nextCursor !== null
182+ const isLoadingMore = hasQuery
183+ ? isSearching && searchResults . length > 0
184+ : listPage === undefined && pages . length > 0
100185
101186 return (
102187 < main className = "section" >
@@ -108,7 +193,7 @@ export function SkillsIndex() {
108193 < p className = "section-subtitle" style = { { marginBottom : 0 } } >
109194 { isLoadingSkills
110195 ? 'Loading skills…'
111- : `${ showing } ${ typeof total === 'number' ? ` of ${ total } ` : '' } skills ${
196+ : `${ showing } skill ${ showing === 1 ? '' : 's' } ${
112197 highlightedOnly ? ' (highlighted)' : ''
113198 } .`}
114199 </ p >
@@ -276,6 +361,28 @@ export function SkillsIndex() {
276361 } ) }
277362 </ div >
278363 ) }
364+
365+ { canLoadMore ? (
366+ < div
367+ className = "card"
368+ style = { { marginTop : 16 , display : 'flex' , justifyContent : 'center' } }
369+ >
370+ < button
371+ className = "btn"
372+ type = "button"
373+ disabled = { isLoadingMore }
374+ onClick = { ( ) => {
375+ if ( hasQuery ) {
376+ setSearchLimit ( ( value ) => value + pageSize )
377+ } else if ( nextCursor ) {
378+ setCursor ( nextCursor )
379+ }
380+ } }
381+ >
382+ { isLoadingMore ? 'Loading…' : 'Load more' }
383+ </ button >
384+ </ div >
385+ ) : null }
279386 </ main >
280387 )
281388}
0 commit comments