@@ -4,6 +4,7 @@ import React, {
44 useMemo ,
55 useCallback ,
66 useRef ,
7+ useTransition ,
78 // @ts -expect-error - unstable_ViewTransition is not yet in @types/react
89 unstable_ViewTransition as ViewTransition ,
910} from "react" ;
@@ -17,6 +18,12 @@ import {
1718 MOBILE_MAX_WIDTH ,
1819} from "../../../legacy-client/src/constants" ;
1920import { getMuseumPageSkins , GridSkin } from "./getMuseumPageSkins" ;
21+ import { searchSkins as performAlgoliaSearch } from "./algoliaClient" ;
22+
23+ // Simple utility to get screenshot URL (avoiding server-side import)
24+ function getScreenshotUrl ( md5 : string ) : string {
25+ return `https://r2.webampskins.org/screenshots/${ md5 } .png` ;
26+ }
2027
2128type CellData = {
2229 skins : GridSkin [ ] ;
@@ -97,18 +104,79 @@ export default function SkinTable({
97104} : SkinTableProps ) {
98105 const { windowWidth, windowHeight } = useWindowSize ( ) ;
99106
100- // Initialize state with server-provided data
101- const [ skins , setSkins ] = useState < GridSkin [ ] > ( initialSkins ) ;
107+ // Search input state - separate input value from actual search query
108+ const [ inputValue , setInputValue ] = useState ( "" ) ;
109+
110+ // State for browsing mode
111+ const [ browseSkins , setBrowseSkins ] = useState < GridSkin [ ] > ( initialSkins ) ;
102112 const [ loadedPages , setLoadedPages ] = useState < Set < number > > ( new Set ( [ 0 ] ) ) ;
103113 const isLoadingRef = useRef ( false ) ;
104114
115+ // State for search mode
116+ const [ searchSkins , setSearchSkins ] = useState < GridSkin [ ] > ( [ ] ) ;
117+ const [ searchError , setSearchError ] = useState < string | null > ( null ) ;
118+ const [ searchIsPending , setSearchIsPending ] = useState ( false ) ;
119+
120+ // Debounce timer ref
121+
122+ // Determine which mode we're in based on actual search query, not input
123+ const isSearchMode = inputValue . trim ( ) . length > 0 ;
124+ const skins = isSearchMode ? searchSkins : browseSkins ;
125+ const total = isSearchMode ? searchSkins . length : initialTotal ;
126+
127+ // Handle search input change
128+ const handleSearchChange = async ( e : React . ChangeEvent < HTMLInputElement > ) => {
129+ const query = e . target . value ;
130+ setInputValue ( query ) ;
131+
132+ // If query is empty, clear results immediately
133+ if ( ! query || query . trim ( ) . length === 0 ) {
134+ return ;
135+ // setSearchQuery("");
136+ startTransition ( ( ) => {
137+ setSearchSkins ( [ ] ) ;
138+ setSearchError ( null ) ;
139+ } ) ;
140+ return ;
141+ }
142+ // return;
143+
144+ try {
145+ setSearchIsPending ( true ) ;
146+ const result = await performAlgoliaSearch ( query ) ;
147+ const hits = result . hits as Array < {
148+ objectID : string ;
149+ fileName : string ;
150+ nsfw ?: boolean ;
151+ } > ;
152+ const searchResults : GridSkin [ ] = hits . map ( ( hit ) => ( {
153+ md5 : hit . objectID ,
154+ screenshotUrl : getScreenshotUrl ( hit . objectID ) ,
155+ fileName : hit . fileName ,
156+ nsfw : hit . nsfw ?? false ,
157+ } ) ) ;
158+ setSearchSkins ( searchResults ) ;
159+ } catch ( err ) {
160+ console . error ( "Search failed:" , err ) ;
161+ setSearchError ( "Search failed. Please try again." ) ;
162+ setSearchSkins ( [ ] ) ;
163+ } finally {
164+ setSearchIsPending ( false ) ;
165+ }
166+ } ;
167+
105168 const columnCount = Math . round ( windowWidth / ( SCREENSHOT_WIDTH * 0.9 ) ) ;
106169 const columnWidth = windowWidth / columnCount ;
107170 const rowHeight = columnWidth * SKIN_RATIO ;
108171 const pageSize = 50 ; // Number of skins to load per page
109172
110173 const loadMoreSkins = useCallback (
111174 async ( startIndex : number ) => {
175+ // Don't load more in search mode
176+ if ( isSearchMode ) {
177+ return ;
178+ }
179+
112180 const pageNumber = Math . floor ( startIndex / pageSize ) ;
113181
114182 // Don't reload if we already have this page
@@ -120,28 +188,25 @@ export default function SkinTable({
120188 try {
121189 const offset = pageNumber * pageSize ;
122190 const newSkins = await getMuseumPageSkins ( offset , pageSize ) ;
123- setSkins ( ( prev ) => [ ...prev , ...newSkins ] ) ;
191+ setBrowseSkins ( ( prev ) => [ ...prev , ...newSkins ] ) ;
124192 setLoadedPages ( ( prev ) => new Set ( [ ...prev , pageNumber ] ) ) ;
125193 } catch ( error ) {
126194 console . error ( "Failed to load skins:" , error ) ;
127195 } finally {
128196 isLoadingRef . current = false ;
129197 }
130198 } ,
131- [ loadedPages , pageSize ]
199+ [ loadedPages , pageSize , isSearchMode ]
132200 ) ;
133201
134- function itemKey ( {
135- columnIndex,
136- rowIndex,
137- } : {
138- columnIndex : number ;
139- rowIndex : number ;
140- } ) {
141- const index = rowIndex * columnCount + columnIndex ;
142- const skin = skins [ index ] ;
143- return skin ? skin . md5 : `empty-cell-${ columnIndex } -${ rowIndex } ` ;
144- }
202+ const itemKey = useCallback (
203+ ( { columnIndex, rowIndex } : { columnIndex : number ; rowIndex : number } ) => {
204+ const index = rowIndex * columnCount + columnIndex ;
205+ const skin = skins [ index ] ;
206+ return skin ? skin . md5 : `empty-cell-${ columnIndex } -${ rowIndex } ` ;
207+ } ,
208+ [ columnCount , skins ]
209+ ) ;
145210
146211 const gridRef = React . useRef < any > ( null ) ;
147212 const itemRef = React . useRef < number > ( 0 ) ;
@@ -152,7 +217,7 @@ export default function SkinTable({
152217 itemRef . current =
153218 Math . round ( scrollData . scrollTop / rowHeight ) * columnCount + half ;
154219 } ;
155- } , [ columnCount , rowHeight , loadMoreSkins ] ) ;
220+ } , [ columnCount , rowHeight ] ) ;
156221
157222 const itemData : CellData = useMemo (
158223 ( ) => ( {
@@ -171,7 +236,7 @@ export default function SkinTable({
171236 < div
172237 style = { {
173238 position : "fixed" ,
174- bottom : "5rem " ,
239+ bottom : "4.25rem " ,
175240 left : "50%" ,
176241 transform : "translateX(-50%)" ,
177242 width : "calc(100% - 2rem)" ,
@@ -180,58 +245,88 @@ export default function SkinTable({
180245 zIndex : 998 ,
181246 } }
182247 >
183- < form
184- action = "/"
185- method = "GET"
186- style = { {
187- width : "100%" ,
188- } }
189- >
248+ < div style = { { position : "relative" } } >
190249 < input
191250 type = "search"
192- name = "q"
251+ value = { inputValue }
252+ onChange = { handleSearchChange }
193253 placeholder = "Search skins..."
194254 style = { {
195255 width : "100%" ,
196256 padding : "0.75rem 1rem" ,
257+ paddingRight : "1rem" ,
197258 fontSize : "1rem" ,
198- backgroundColor : "rgba(26, 26, 26, 0.85 )" ,
259+ backgroundColor : "rgba(26, 26, 26, 0.55 )" ,
199260 backdropFilter : "blur(10px)" ,
200261 border : "1px solid rgba(255, 255, 255, 0.2)" ,
201262 borderRadius : "9999px" ,
202263 color : "#fff" ,
203264 outline : "none" ,
204265 fontFamily : "inherit" ,
205266 boxShadow : "0 4px 12px rgba(0, 0, 0, 0.3)" ,
267+ transition : "padding-right 0.2s ease" ,
206268 } }
207269 onFocus = { ( e ) => {
208- e . currentTarget . style . backgroundColor = "rgba(26, 26, 26, 0.95 )" ;
270+ e . currentTarget . style . backgroundColor = "rgba(26, 26, 26, 0.65 )" ;
209271 e . currentTarget . style . borderColor = "rgba(255, 255, 255, 0.3)" ;
210272 } }
211273 onBlur = { ( e ) => {
212- e . currentTarget . style . backgroundColor = "rgba(26, 26, 26, 0.85 )" ;
274+ e . currentTarget . style . backgroundColor = "rgba(26, 26, 26, 0.55 )" ;
213275 e . currentTarget . style . borderColor = "rgba(255, 255, 255, 0.2)" ;
214276 } }
215277 />
216- </ form >
278+ </ div >
217279 </ div >
218280
219- < Grid
220- ref = { gridRef }
221- itemKey = { itemKey }
222- itemData = { itemData }
223- columnCount = { columnCount }
224- columnWidth = { columnWidth }
225- height = { windowHeight }
226- rowCount = { Math . ceil ( initialTotal / columnCount ) }
227- rowHeight = { rowHeight }
228- width = { windowWidth }
229- overscanRowsCount = { 5 }
230- onScroll = { onScroll }
231- style = { { overflowY : "scroll" } }
232- >
233- { Cell }
234- </ Grid >
281+ { /* Error State */ }
282+ { isSearchMode && searchError && (
283+ < div
284+ style = { {
285+ display : "flex" ,
286+ justifyContent : "center" ,
287+ alignItems : "center" ,
288+ height : windowHeight ,
289+ color : "#ff6b6b" ,
290+ } }
291+ >
292+ { searchError }
293+ </ div >
294+ ) }
295+
296+ { /* Empty Results */ }
297+ { isSearchMode && ! searchError && skins . length === 0 && (
298+ < div
299+ style = { {
300+ display : "flex" ,
301+ justifyContent : "center" ,
302+ alignItems : "center" ,
303+ height : windowHeight ,
304+ color : "#ccc" ,
305+ } }
306+ >
307+ No results found for "{ inputValue } "
308+ </ div >
309+ ) }
310+
311+ { /* Grid - show when browsing or when we have results (even while pending) */ }
312+ { ( ! isSearchMode || ( ! searchError && skins . length > 0 ) ) && (
313+ < Grid
314+ ref = { gridRef }
315+ itemKey = { itemKey }
316+ itemData = { itemData }
317+ columnCount = { columnCount }
318+ columnWidth = { columnWidth }
319+ height = { windowHeight }
320+ rowCount = { Math . ceil ( total / columnCount ) }
321+ rowHeight = { rowHeight }
322+ width = { windowWidth }
323+ overscanRowsCount = { 5 }
324+ onScroll = { onScroll }
325+ style = { { overflowY : "scroll" } }
326+ >
327+ { Cell }
328+ </ Grid >
329+ ) }
235330 </ div >
236331 ) ;
237332}
0 commit comments