11import { cachedOpenAlex } from "@bibgraph/client" ;
22import type { AutocompleteResult } from "@bibgraph/types" ;
33import { ENTITY_METADATA , toEntityType } from "@bibgraph/types" ;
4- import { convertToRelativeUrl , ErrorRecovery , SearchEmptyState } from "@bibgraph/ui" ;
4+ import { convertToRelativeUrl , ErrorRecovery , SearchEmptyState } from "@bibgraph/ui" ;
55import { formatLargeNumber , logger } from "@bibgraph/utils" ;
66import {
77 Anchor ,
@@ -10,18 +10,25 @@ import {
1010 Card ,
1111 Container ,
1212 Group ,
13+ Paper ,
14+ SegmentedControl ,
15+ SimpleGrid ,
1316 Stack ,
1417 Table ,
1518 Text ,
1619 Title ,
20+ Tooltip ,
1721} from "@mantine/core" ;
1822import { notifications } from "@mantine/notifications" ;
1923import {
2024 IconBookmark ,
2125 IconBookmarkOff ,
26+ IconLayoutGrid ,
27+ IconList ,
28+ IconTable ,
2229} from "@tabler/icons-react" ;
2330import { useQuery , useQueryClient } from "@tanstack/react-query" ;
24- import { createLazyFileRoute , useSearch } from "@tanstack/react-router" ;
31+ import { createLazyFileRoute , useSearch } from "@tanstack/react-router" ;
2532import { useEffect , useMemo , useState } from "react" ;
2633
2734import { BORDER_STYLE_GRAY_3 , ICON_SIZE , SEARCH , TIME_MS } from '@/config/style-constants' ;
@@ -35,6 +42,20 @@ interface SearchFilters {
3542 query : string ;
3643}
3744
45+ type ViewMode = "table" | "card" | "list" ;
46+
47+ // Calculate entity type breakdown from results
48+ const getEntityTypeBreakdown = ( results : AutocompleteResult [ ] ) => {
49+ const breakdown = results . reduce ( ( acc , result ) => {
50+ acc [ result . entity_type ] = ( acc [ result . entity_type ] || 0 ) + 1 ;
51+ return acc ;
52+ } , { } as Record < string , number > ) ;
53+
54+ return Object . entries ( breakdown )
55+ . map ( ( [ type , count ] ) => ( { type, count } ) )
56+ . sort ( ( a , b ) => b . count - a . count ) ;
57+ } ;
58+
3859// Real OpenAlex API autocomplete function - searches across all entity types
3960const searchAllEntities = async (
4061 filters : SearchFilters ,
@@ -135,6 +156,16 @@ const SearchPage = () => {
135156 const searchParams = useSearch ( { from : "/search" } ) ;
136157 const queryClient = useQueryClient ( ) ;
137158
159+ // View mode state
160+ const [ viewMode , setViewMode ] = useState < ViewMode > ( "table" ) ;
161+
162+ // Entity type filter state
163+ const [ selectedTypes , setSelectedTypes ] = useState < string [ ] > ( [ ] ) ;
164+
165+ // Search duration tracking
166+ const [ searchStartTime , setSearchStartTime ] = useState < number > ( 0 ) ;
167+ const [ searchDuration , setSearchDuration ] = useState < number > ( 0 ) ;
168+
138169 // Derive initial search filters from URL parameters using useMemo
139170 const initialSearchFilters = useMemo < SearchFilters > ( ( ) => {
140171 const qParam = searchParams . q ;
@@ -185,6 +216,7 @@ const SearchPage = () => {
185216 } ) ;
186217
187218 const handleSearch = async ( filters : SearchFilters ) => {
219+ setSearchStartTime ( Date . now ( ) ) ;
188220 setSearchFilters ( filters ) ;
189221 // Auto-tracking in useUserInteractions will handle page visit recording
190222 } ;
@@ -250,11 +282,26 @@ const SearchPage = () => {
250282 if ( searchResults && searchResults . length > 0 && retryCount > 0 ) {
251283 setRetryCount ( 0 ) ;
252284 }
253- } , [ searchResults , retryCount ] ) ;
285+ if ( searchResults && searchStartTime > 0 ) {
286+ setSearchDuration ( Date . now ( ) - searchStartTime ) ;
287+ }
288+ } , [ searchResults , retryCount , searchStartTime ] ) ;
254289
255290 const hasResults = searchResults && searchResults . length > 0 ;
256291 const hasQuery = Boolean ( searchFilters . query . trim ( ) ) ;
257292
293+ // Filter results by selected entity types
294+ const filteredResults = useMemo ( ( ) => {
295+ if ( ! searchResults ) return [ ] ;
296+ if ( selectedTypes . length === 0 ) return searchResults ;
297+ return searchResults . filter ( result => selectedTypes . includes ( result . entity_type ) ) ;
298+ } , [ searchResults , selectedTypes ] ) ;
299+
300+ // Calculate entity type breakdown
301+ const entityTypeBreakdown = useMemo ( ( ) => {
302+ return searchResults ? getEntityTypeBreakdown ( searchResults ) : [ ] ;
303+ } , [ searchResults ] ) ;
304+
258305 const renderSearchResults = ( ) => {
259306 if ( isLoading ) return renderLoadingState ( ) ;
260307 if ( error ) return renderErrorState (
@@ -267,15 +314,105 @@ const SearchPage = () => {
267314 ) ;
268315 if ( ! hasResults ) return renderNoResultsState ( searchFilters . query , handleQuickSearch ) ;
269316
317+ const displayResults = selectedTypes . length > 0 ? filteredResults : searchResults ;
318+
270319 return (
271320 < Stack >
272- < Group justify = "space-between" align = "center" >
273- < Text size = "sm" c = "dimmed" >
274- Found { searchResults . length } results for "
275- { searchFilters . query } "
276- </ Text >
321+ { /* Enhanced Results Header */ }
322+ < Stack gap = "md" >
323+ { /* Primary stats row */ }
324+ < Group justify = "space-between" align = "center" wrap = "nowrap" >
325+ < Group gap = "md" align = "center" >
326+ < Text size = "sm" fw = { 500 } >
327+ { displayResults . length } { displayResults . length === 1 ? 'result' : 'results' }
328+ { selectedTypes . length > 0 && ` (filtered from ${ searchResults . length } )` }
329+ </ Text >
330+ { searchDuration > 0 && (
331+ < Tooltip label = { `${ searchDuration } ms from OpenAlex API` } >
332+ < Text size = "xs" c = "dimmed" style = { { cursor : 'help' } } >
333+ { ( searchDuration / 1000 ) . toFixed ( 2 ) } s
334+ </ Text >
335+ </ Tooltip >
336+ ) }
337+ </ Group >
338+
339+ { /* View mode toggle */ }
340+ < SegmentedControl
341+ value = { viewMode }
342+ onChange = { ( value ) => setViewMode ( value as ViewMode ) }
343+ data = { [
344+ {
345+ value : 'table' ,
346+ label : (
347+ < Tooltip label = "Table view" > < IconTable size = { ICON_SIZE . SM } /> </ Tooltip >
348+ )
349+ } ,
350+ {
351+ value : 'card' ,
352+ label : (
353+ < Tooltip label = "Card view" > < IconLayoutGrid size = { ICON_SIZE . SM } /> </ Tooltip >
354+ )
355+ } ,
356+ {
357+ value : 'list' ,
358+ label : (
359+ < Tooltip label = "List view" > < IconList size = { ICON_SIZE . SM } /> </ Tooltip >
360+ )
361+ } ,
362+ ] }
363+ size = "xs"
364+ />
365+ </ Group >
366+
367+ { /* Entity type breakdown and filter chips */ }
368+ { entityTypeBreakdown . length > 0 && (
369+ < Stack gap = "xs" >
370+ < Group gap = "xs" wrap = "wrap" >
371+ < Text size = "xs" c = "dimmed" > Filter by type:</ Text >
372+ { entityTypeBreakdown . map ( ( { type, count } ) => {
373+ const isSelected = selectedTypes . includes ( type ) ;
374+ // Get color safely using toEntityType helper, defaulting to gray for unknown types
375+ const pluralForm = toEntityType ( type ) ;
376+ const color = pluralForm && pluralForm in ENTITY_METADATA
377+ ? ENTITY_METADATA [ pluralForm ] . color
378+ : "gray" ;
379+ return (
380+ < Badge
381+ key = { type }
382+ size = "sm"
383+ color = { color }
384+ variant = { isSelected ? "filled" : "light" }
385+ leftSection = { isSelected ? "✓ " : undefined }
386+ style = { { cursor : 'pointer' , userSelect : 'none' } }
387+ onClick = { ( ) => {
388+ setSelectedTypes ( prev =>
389+ prev . includes ( type )
390+ ? prev . filter ( t => t !== type )
391+ : [ ...prev , type ]
392+ ) ;
393+ } }
394+ >
395+ { type } ({ count } )
396+ </ Badge >
397+ ) ;
398+ } ) }
399+ { selectedTypes . length > 0 && (
400+ < Button
401+ size = "xs"
402+ variant = "subtle"
403+ onClick = { ( ) => setSelectedTypes ( [ ] ) }
404+ >
405+ Clear filters
406+ </ Button >
407+ ) }
408+ </ Group >
409+ </ Stack >
410+ ) }
411+ </ Stack >
277412
278- { hasQuery && (
413+ { /* Bookmark button */ }
414+ { hasQuery && (
415+ < Group justify = "end" >
279416 < Button
280417 variant = "light"
281418 color = { userInteractions . isBookmarked ? "yellow" : "gray" }
@@ -337,23 +474,22 @@ const SearchPage = () => {
337474 >
338475 { userInteractions . isBookmarked ? "Bookmarked" : "Bookmark Search" }
339476 </ Button >
340- ) }
341- </ Group >
342-
343- { /* Using Mantine Table directly due to TanStack Table hook compatibility
344- issues with lazy-loaded routes. BaseTable's useReactTable/useVirtualizer
345- hooks cause "Invalid hook call" errors in this lazy route context. */ }
346- < Table striped highlightOnHover withTableBorder >
347- < Table . Thead >
348- < Table . Tr >
349- < Table . Th w = { 100 } > Type</ Table . Th >
350- < Table . Th > Name</ Table . Th >
351- < Table . Th w = { 100 } > Citations</ Table . Th >
352- < Table . Th w = { 100 } > Works</ Table . Th >
353- </ Table . Tr >
354- </ Table . Thead >
355- < Table . Tbody >
356- { searchResults . map ( ( result ) => {
477+ </ Group >
478+ ) }
479+
480+ { /* Results display based on view mode */ }
481+ { viewMode === "table" && (
482+ < Table striped highlightOnHover withTableBorder >
483+ < Table . Thead >
484+ < Table . Tr >
485+ < Table . Th w = { 100 } > Type</ Table . Th >
486+ < Table . Th > Name</ Table . Th >
487+ < Table . Th w = { 100 } > Citations</ Table . Th >
488+ < Table . Th w = { 100 } > Works</ Table . Th >
489+ </ Table . Tr >
490+ </ Table . Thead >
491+ < Table . Tbody >
492+ { displayResults . map ( ( result ) => {
357493 const entityUrl = convertToRelativeUrl ( result . id ) ;
358494 return (
359495 < Table . Tr key = { result . id } >
@@ -398,6 +534,94 @@ const SearchPage = () => {
398534 } ) }
399535 </ Table . Tbody >
400536 </ Table >
537+ ) }
538+
539+ { viewMode === "card" && (
540+ < SimpleGrid cols = { { base : 1 , xs : 2 , sm : 2 , md : 3 , lg : 4 } } spacing = "md" >
541+ { displayResults . map ( ( result ) => {
542+ const entityUrl = convertToRelativeUrl ( result . id ) ;
543+ return (
544+ < Card
545+ key = { result . id }
546+ shadow = "sm"
547+ p = "md"
548+ withBorder
549+ style = { { cursor : 'pointer' } }
550+ component = { entityUrl ? "a" : "div" }
551+ href = { entityUrl }
552+ >
553+ < Stack gap = "xs" >
554+ < Group justify = "space-between" align = "start" >
555+ < Badge size = "sm" color = { getEntityTypeColor ( result . entity_type ) } variant = "light" >
556+ { result . entity_type }
557+ </ Badge >
558+ < Text size = "xs" c = "dimmed" >
559+ { result . cited_by_count ? `${ formatLargeNumber ( result . cited_by_count ) } citations` : '—' }
560+ </ Text >
561+ </ Group >
562+ < Text size = "sm" fw = { 500 } lineClamp = { 2 } >
563+ { result . display_name }
564+ </ Text >
565+ { result . hint && (
566+ < Text size = "xs" c = "dimmed" lineClamp = { 1 } >
567+ { result . hint }
568+ </ Text >
569+ ) }
570+ </ Stack >
571+ </ Card >
572+ ) ;
573+ } ) }
574+ </ SimpleGrid >
575+ ) }
576+
577+ { viewMode === "list" && (
578+ < Stack gap = "xs" >
579+ { displayResults . map ( ( result ) => {
580+ const entityUrl = convertToRelativeUrl ( result . id ) ;
581+ return (
582+ < Paper
583+ key = { result . id }
584+ withBorder
585+ p = "sm"
586+ radius = "md"
587+ style = { { cursor : 'pointer' } }
588+ component = { entityUrl ? "a" : "div" }
589+ href = { entityUrl }
590+ >
591+ < Group justify = "space-between" align = "center" >
592+ < Group gap = "sm" align = "start" style = { { flex : 1 } } >
593+ < Badge size = "xs" color = { getEntityTypeColor ( result . entity_type ) } variant = "light" >
594+ { result . entity_type }
595+ </ Badge >
596+ < Stack gap = { 0 } style = { { flex : 1 } } >
597+ < Text size = "sm" fw = { 500 } >
598+ { result . display_name }
599+ </ Text >
600+ { result . hint && (
601+ < Text size = "xs" c = "dimmed" lineClamp = { 1 } >
602+ { result . hint }
603+ </ Text >
604+ ) }
605+ </ Stack >
606+ </ Group >
607+ < Group gap = "md" >
608+ { result . cited_by_count && (
609+ < Text size = "xs" c = "dimmed" ta = "right" >
610+ { formatLargeNumber ( result . cited_by_count ) } citations
611+ </ Text >
612+ ) }
613+ { result . works_count && (
614+ < Text size = "xs" c = "dimmed" ta = "right" >
615+ { formatLargeNumber ( result . works_count ) } works
616+ </ Text >
617+ ) }
618+ </ Group >
619+ </ Group >
620+ </ Paper >
621+ ) ;
622+ } ) }
623+ </ Stack >
624+ ) }
401625 </ Stack >
402626 ) ;
403627 } ;
0 commit comments