@@ -1694,6 +1694,7 @@ export function SearchView() {
16941694 const [ librariesLoading , setLibrariesLoading ] = useState ( libraries . length === 0 ) ;
16951695 const [ filtersOpen , setFiltersOpen ] = useState ( false ) ;
16961696 const [ results , setResults ] = useState < SearchResult [ ] > ( [ ] ) ;
1697+ const groupedResults = groupSearchResults ( results ) ;
16971698
16981699 useEffect ( ( ) => {
16991700 void apiGet < Library [ ] > ( "/api/v1/libraries" )
@@ -1784,52 +1785,81 @@ export function SearchView() {
17841785 ) : null }
17851786 </ CardHeader >
17861787 < CardContent className = "space-y-3" >
1787- { results . map ( ( result ) => {
1788- const snippets = resultSnippets ( result ) ;
1789- const displayPath = resultDisplayPath ( result ) ;
1790- return (
1791- < div key = { `${ result . library } -${ result . path || result . title } -${ result . score } ` } className = "rounded-lg border border-border p-4" >
1792- < div className = "min-w-0" >
1793- < div className = "flex flex-wrap items-start justify-between gap-3" >
1794- < div className = "min-w-0" >
1795- < h3 className = "text-base font-semibold leading-6 tracking-normal" > { result . title } </ h3 >
1796- < div className = "mt-1 truncate text-xs text-muted-foreground" >
1797- < Link to = "/catalog/$slug" params = { { slug : result . library } } className = "font-medium text-foreground hover:underline" >
1798- { result . library }
1799- </ Link >
1800- { result . source && < span className = "whitespace-nowrap" > /{ result . source } </ span > }
1788+ { groupedResults . map ( ( group ) => (
1789+ < div key = { group . library } className = "overflow-hidden rounded-lg border border-border" >
1790+ < div className = "flex flex-wrap items-center justify-between gap-3 bg-muted/50 px-4 py-3" >
1791+ < div className = "min-w-0 truncate text-sm" >
1792+ < Link to = "/catalog/$slug" params = { { slug : group . library } } className = "font-semibold text-foreground hover:underline" >
1793+ { group . library }
1794+ </ Link >
1795+ { group . sources . length === 1 && < span className = "whitespace-nowrap text-muted-foreground" > /{ group . sources [ 0 ] } </ span > }
1796+ { group . sources . length > 1 && < span className = "text-muted-foreground" > /{ group . sources . join ( ", " ) } </ span > }
1797+ </ div >
1798+ < span className = "shrink-0 text-xs text-muted-foreground" > { group . results . length } { group . results . length === 1 ? "match" : "matches" } </ span >
1799+ </ div >
1800+ { group . results . map ( ( result ) => {
1801+ const snippets = resultSnippets ( result ) ;
1802+ const displayPath = resultDisplayPath ( result ) ;
1803+ return (
1804+ < div key = { `${ result . library } -${ result . path || result . title } -${ result . score } ` } className = "border-t border-border p-4 first:border-t-0" >
1805+ < div className = "flex flex-wrap items-start justify-between gap-3" >
1806+ < div className = "min-w-0" >
1807+ < h3 className = "text-base font-semibold leading-6 tracking-normal" > { result . title } </ h3 >
1808+ { group . sources . length > 1 && result . source && < p className = "mt-1 text-xs text-muted-foreground" > { result . source } </ p > }
18011809 </ div >
1810+ { result . source_url && (
1811+ < a className = "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" href = { result . source_url } target = "_blank" rel = "noreferrer" >
1812+ Source < ExternalLink className = "h-3 w-3" />
1813+ </ a >
1814+ ) }
18021815 </ div >
1803- { result . source_url && (
1804- < a className = "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" href = { result . source_url } target = "_blank" rel = "noreferrer" >
1805- Source < ExternalLink className = "h-3 w-3" />
1806- </ a >
1816+ { displayPath && (
1817+ < p className = "mt-2 truncate font-mono text-xs text-muted-foreground" > { displayPath } </ p >
1818+ ) }
1819+ { result . description && < p className = "mt-3 line-clamp-2 text-sm text-muted-foreground" > { result . description } </ p > }
1820+ { snippets . length > 0 ? (
1821+ < div className = "mt-3 space-y-2 text-sm leading-6" >
1822+ { snippets . map ( ( match , index ) => (
1823+ < p key = { `${ result . title } -${ index } ` } className = "line-clamp-3" > { renderMarked ( match ) } </ p >
1824+ ) ) }
1825+ </ div >
1826+ ) : (
1827+ result . snippet && < p className = "mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground" > { result . snippet } </ p >
18071828 ) }
18081829 </ div >
1809- { displayPath && (
1810- < p className = "mt-2 truncate font-mono text-xs text-muted-foreground" > { displayPath } </ p >
1811- ) }
1812- { result . description && < p className = "mt-3 line-clamp-2 text-sm text-muted-foreground" > { result . description } </ p > }
1813- { snippets . length > 0 ? (
1814- < div className = "mt-3 space-y-2 text-sm leading-6" >
1815- { snippets . map ( ( match , index ) => (
1816- < p key = { `${ result . title } -${ index } ` } className = "line-clamp-3" > { renderMarked ( match ) } </ p >
1817- ) ) }
1818- </ div >
1819- ) : (
1820- result . snippet && < p className = "mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground" > { result . snippet } </ p >
1821- ) }
1822- </ div >
1823- </ div >
1824- ) ;
1825- } ) }
1826- { results . length === 0 && < p className = "text-sm text-muted-foreground" > No results found.</ p > }
1830+ ) ;
1831+ } ) }
1832+ </ div >
1833+ ) ) }
1834+ { groupedResults . length === 0 && < p className = "text-sm text-muted-foreground" > No results found.</ p > }
18271835 </ CardContent >
18281836 </ Card >
18291837 </ section >
18301838 ) ;
18311839}
18321840
1841+ type SearchResultGroup = {
1842+ library : string ;
1843+ sources : string [ ] ;
1844+ results : SearchResult [ ] ;
1845+ } ;
1846+
1847+ function groupSearchResults ( results : SearchResult [ ] ) : SearchResultGroup [ ] {
1848+ const groups = new Map < string , SearchResultGroup > ( ) ;
1849+ for ( const result of results ) {
1850+ let group = groups . get ( result . library ) ;
1851+ if ( ! group ) {
1852+ group = { library : result . library , sources : [ ] , results : [ ] } ;
1853+ groups . set ( result . library , group ) ;
1854+ }
1855+ group . results . push ( result ) ;
1856+ if ( result . source && ! group . sources . includes ( result . source ) ) {
1857+ group . sources . push ( result . source ) ;
1858+ }
1859+ }
1860+ return [ ...groups . values ( ) ] ;
1861+ }
1862+
18331863function resultDisplayPath ( result : SearchResult ) {
18341864 const path = result . path || "" ;
18351865 if ( ! path ) return "" ;
0 commit comments